From d412fc147398b92cc17dffd46afa0a3a1ab02cc7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 22:46:00 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 5 +- .../com/tangem/scenarios/SwapScenarios.kt | 111 ++++- .../tangem/screens/SwapSuccessPageObject.kt | 7 + .../com/tangem/screens/SwapTokenPageObject.kt | 6 + .../tangem/tests/transfer/AppTransfersTest.kt | 453 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 21 + 6 files changed, 576 insertions(+), 27 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 08641bc0d6..31b41c8bea 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -140,12 +140,15 @@ fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessC fun BaseTestCase.synchronizeAddresses( balance: String? = null, - isBalanceAvailable: Boolean = true + isBalanceAvailable: Boolean = true, + assertBalance: Boolean = true, ) { step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } + if (!assertBalance) return + when { !isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index db4e1a2771..77403f32f0 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -11,10 +11,10 @@ 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.clickAndWaitFor import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText +import com.tangem.common.extensions.isDisplayedSafely import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.HotWalletAccessCodeTestTags @@ -262,6 +262,24 @@ fun BaseTestCase.checkSwapWarning( } } +/** Scans a card wallet and opens Swap for [tokenName] in [fromAccountName] without choosing the receive token yet. */ +fun BaseTestCase.openSwapForTokenInAccount( + tokenName: String, + fromAccountName: String = "Account 1", + mockContent: MockContent? = null, +) { + step("Open 'Main' screen") { + openMainScreen(mockContent = mockContent) + } + step("Synchronize addresses") { + synchronizeAddresses(assertBalance = false) + } + step("Wait for addresses to be generated") { + waitForAddressesGenerated() + } + navigateToSwapForToken(tokenName, fromAccountName) +} + /** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */ fun BaseTestCase.openSwapInTransferMode( tokenName: String, @@ -269,36 +287,59 @@ fun BaseTestCase.openSwapInTransferMode( toAccountName: String = "Account 2", mockContent: MockContent? = null, ) { - step("Open 'Main' screen") { - openMainScreen(mockContent = mockContent) - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") { - onMainScreen { scrollToAccount(fromAccountName) } - } - step("Expand account '$fromAccountName' and reveal token '$tokenName'") { - onMainScreen { - findAccountSectionByName(fromAccountName).clickAndWaitFor( - rule = composeTestRule, - expectedCondition = { - onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() } - }, - ) - } - } - step("Click on token with name: '$tokenName'") { - onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) - } + openSwapForTokenInAccount(tokenName, fromAccountName, mockContent) step("Choose identical receive token '$tokenName' from '$toAccountName'") { chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) } } +/** Like [openSwapInTransferMode] but imports a hot wallet first — required for broadcasting flows (the mock card can't sign). */ +fun BaseTestCase.openSwapInTransferModeWithHotWallet( + tokenName: String, + seedPhrase: String, + fromAccountName: String = "Account 1", + toAccountName: String = "Account 2", +) { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Generate missing addresses") { + generateMissingHotWalletAddresses() + } + step("Wait for addresses to be generated") { + waitForAddressesGenerated() + } + navigateToSwapForToken(tokenName, fromAccountName) + step("Choose identical receive token '$tokenName' from '$toAccountName'") { + chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) + } +} + +// Mirrors iOS: enter Swap via the main action button (source auto-selected) — avoids the account list under the Markets promo. +private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) { + step("Open 'Swap' for '$tokenName' from '$fromAccountName' via the main 'Swap' button") { + openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) + } +} + +// Hot wallets derive locally, so the second account's missing addresses are generated without a card scan when prompted. +fun BaseTestCase.generateMissingHotWalletAddresses() { + var notificationShown = false + onMainScreen { notificationShown = synchronizeAddressesButton.isDisplayedSafely() } + if (notificationShown) { + onMainScreen { synchronizeAddressesButton.performClick() } + } +} + +// The receive selector shows "No address" until the second account's derivation lands; the prompt disappears when it does. +fun BaseTestCase.waitForAddressesGenerated() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + var generated = false + onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() } + generated + } +} + /** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */ fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) { step("Click on 'Choose token' button") { @@ -321,6 +362,16 @@ fun BaseTestCase.chooseReceiveToken(tokenName: String) { } } +/** Reopens the receive selector via the receive-card icon and picks [tokenName] directly — the reopened selector keeps the account expanded. */ +fun BaseTestCase.changeReceiveToken(tokenName: String) { + step("Open receive token selector") { + onSwapTokenScreen { receiveSelectTokenIcon.performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + /** * 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 @@ -423,6 +474,14 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { } } +/** Holds the last BASE_BUTTON to confirm a transfer; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */ +fun BaseTestCase.holdToConfirmTransfer() { + val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON)) + val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex] + confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } + waitForIdle() +} + sealed class SwapEntryPoint { object MainScreen : SwapEntryPoint() object TokenDetails : SwapEntryPoint() diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt index 3ba2a38384..0dace29264 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt @@ -20,6 +20,13 @@ class SwapSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + // App Transfers reuses the swap success screen; in transfer mode its title is "Transfer in progress". + val transferInProgressTitle: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TITLE) + hasText(getResourceString(R.string.transfer_in_progress_title)) + useUnmergedTree = true + } + val closeButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasAnyDescendant(withText(getResourceString(R.string.common_close))) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index 3bdeff493c..c123dbb27c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -245,6 +245,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_choose_token)) useUnmergedTree = true } + + // Transfer mode auto-fills the memo/destination tag — the manual Send-address field must never render here. + val destinationTagField: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD) + useUnmergedTree = true + } } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index d7897ef413..14e9fb1557 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -4,17 +4,21 @@ import com.tangem.common.BaseTestCase import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText 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.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R as CoreUiR import com.tangem.scenarios.* import com.tangem.screens.* import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -454,4 +458,453 @@ class AppTransfersTest : BaseTestCase() { } } } + + @AllureId("9841") + @DisplayName("App transfers: full transfer reaches 'Transfer in progress' screen") + @Test + fun fullTransferReachesTransferInProgressScreenTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token' with existing hot wallet") { + openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transfer in progress' screen is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("9999") + @DisplayName("App transfers: broadcast error shows alert without finish screen") + @Test + fun broadcastErrorShowsAlertWithoutFinishScreenTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + val sendRawTransactionScenario = "eth_sendRawTransaction" + val broadcastErrorState = "BroadcastError" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(sendRawTransactionScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + step("Set WireMock scenario: '$sendRawTransactionScenario' to state: '$broadcastErrorState'") { + setWireMockScenarioState(scenarioName = sendRawTransactionScenario, state = broadcastErrorState) + } + + step("Open Swap in Transfer mode for '$token' with existing hot wallet") { + openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transaction failed' dialog is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onFailedTransactionDialog { dialogContainer.assertIsDisplayed() } + } + } + step("Assert 'Transfer in progress' screen is not displayed") { + onSwapSuccessScreen { transferInProgressTitle.assertDoesNotExist() } + } + } + } + + @AllureId("9989") + @DisplayName("App transfers: receive list allows identical token on another account") + @Test + fun receiveListAllowsIdenticalTokenOnAnotherAccountTest() { + val token = "Ethereum" + val receiveAccountName = "Account 2" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap for '$token' in 'Account 1'") { openSwapForTokenInAccount(token) } + step("Open receive token selector") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Expand account '$receiveAccountName' in receive selector") { + onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() } + } + step("Assert token '$token' is displayed in receive selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSelectTokenScreen { tokenWithName(token).assertIsDisplayed() } + } + } + } + } + + @AllureId("9998") + @DisplayName("App transfers: fee calculation error disables Transfer") + @Test + fun feeCalculationErrorDisablesTransferTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + val feeHistoryScenario = "eth_fee_history" + val estimateGasScenario = "eth_estimate_gas" + val unreachable = "Unreachable" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(feeHistoryScenario) + resetWireMockScenarioState(estimateGasScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + step("Set WireMock scenario: '$feeHistoryScenario' to state: '$unreachable'") { + setWireMockScenarioState(scenarioName = feeHistoryScenario, state = unreachable) + } + step("Set WireMock scenario: '$estimateGasScenario' to state: '$unreachable'") { + setWireMockScenarioState(scenarioName = estimateGasScenario, state = unreachable) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + // Unreachable fee endpoints leave the fee unresolved (shown as '—'); the transfer stays blocked. + step("Assert 'Transfer' button is disabled") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + } + + @AllureId("9994") + @DisplayName("App transfers: mode switches reactively without screen reload") + @Test + fun modeSwitchesReactivelyWithoutScreenReloadTest() { + val token = "Solana" + val swapReceiveToken = "USDC" + val userTokensState = "TwoAccountsSameSolanaWithUsdc" + val solanaBalanceScenario = "solana_balance" + val assetsScenario = "express_api_assets" + val fromPairsScenario = "solana_from_pairs" + val dexProviderState = "DexProvider" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(assetsScenario) + resetWireMockScenarioState(fromPairsScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + step("Set WireMock scenario: '$assetsScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = assetsScenario, state = started) + } + step("Set WireMock scenario: '$fromPairsScenario' to state: '$dexProviderState'") { + setWireMockScenarioState(scenarioName = fromPairsScenario, state = dexProviderState) + } + // Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Change receive token to '$swapReceiveToken' to switch to Swap mode") { + changeReceiveToken(swapReceiveToken) + } + step("Assert 'Swap' button is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { swapButton.assertIsDisplayed() } + } + } + step("Change receive token back to identical '$token' to switch to Transfer mode") { + changeReceiveToken(token) + } + step("Assert Transfer mode is ready") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { assertTransferReady() } + } + } + } + + @AllureId("10001") + @DisplayName("App transfers: memo field is not entered manually in Transfer mode") + @Test + fun memoFieldIsNotEnteredManuallyInTransferModeTest() { + val token = "XRP Ledger" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXRP" + val rippleAccountInfoScenario = "ripple_account_info" + val quotesRippleState = "Ripple" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(rippleAccountInfoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert manual 'Destination tag' field is not displayed") { + onSwapTokenScreen { destinationTagField.assertDoesNotExist() } + } + } + } + + @AllureId("10009") + @DisplayName("App transfers: XRP network fee") + @Test + fun xrpNetworkFeeTest() { + val token = "XRP Ledger" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXRP" + val rippleAccountInfoScenario = "ripple_account_info" + val quotesRippleState = "Ripple" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(rippleAccountInfoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10011") + @DisplayName("App transfers: Stellar network fee") + @Test + fun stellarNetworkFeeTest() { + val token = "Stellar" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXLM" + val quotesXlmState = "XLM" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesXlmState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesXlmState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + // [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows. + @Ignore("[REDACTED_JIRA]") + @AllureId("9852") + @DisplayName("App transfers: amount below destination reserve disables Transfer") + @Test + fun amountBelowDestinationReserveDisablesTransferTest() { + val token = "Solana" + val belowReserveAmount = "0.0001" + val userTokensState = "TwoAccountsSameSolana" + val solanaBalanceScenario = "solana_balance" + val recipientAccountScenario = "solana_recipient_account" + val notExistState = "NotExist" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(recipientAccountScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + step("Set WireMock scenario: '$recipientAccountScenario' to state: '$notExistState'") { + setWireMockScenarioState(scenarioName = recipientAccountScenario, state = notExistState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$belowReserveAmount'") { inputAmount(belowReserveAmount) } + step("Assert error notification is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { errorNotificationTitle.assertIsDisplayed() } + } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + + @AllureId("9997") + @DisplayName("App transfers: amount below minimum disables Transfer") + @Test + fun amountBelowMinimumDisablesTransferTest() { + val token = "Kaspa" + val belowMinimumAmount = "0.00000001" + val userTokensState = "TwoAccountsSameKaspa" + val kaspaUtxoScenario = "kaspa_utxo" + // Android-specific UTXO body — addresses differ from the iOS fixture (see kaspa-utxo.json). + val kaspaUtxoState = "more_than_84_android" + val quotesKaspaState = "Kaspa" + val invalidAmountTitle = getResourceString(CoreUiR.string.send_notification_invalid_amount_title) + val minimumAmountMessagePrefix = + getResourceString(CoreUiR.string.send_notification_invalid_minimum_amount_text).substringBefore("%1") + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(kaspaUtxoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$kaspaUtxoScenario' to state: '$kaspaUtxoState'") { + setWireMockScenarioState(scenarioName = kaspaUtxoScenario, state = kaspaUtxoState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesKaspaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesKaspaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$belowMinimumAmount'") { inputAmount(belowMinimumAmount) } + step("Assert '$invalidAmountTitle' notification title is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { warningTitle(invalidAmountTitle).assertIsDisplayed() } + } + } + step("Assert notification message contains the minimum-amount text") { + onSwapTokenScreen { errorNotificationText.assertTextContains(minimumAmountMessagePrefix, substring = true) } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 78c6b52b7b..aa7881a6db 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -297,6 +297,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/144'/1'/0/0") to ExtendedPublicKey( // XRP (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/1729'/0'/0'") to ExtendedPublicKey( // Tezos publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -396,6 +403,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), @@ -564,6 +578,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), ), ), ),