Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -24,6 +24,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.tap.MainActivity
@ -63,6 +64,9 @@ abstract class BaseTestCase : TestCase(
@Inject
lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase
@Inject
lateinit var singleAccountListSupplier: SingleAccountListSupplier
private val hiltRule = HiltAndroidRule(this)
private val apiEnvironmentRule = ApiEnvironmentRule()
private val permissionRule = GrantPermissionRule.grant(
@ -183,9 +187,11 @@ abstract class BaseTestCase : TestCase(
"GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
"ADD_AND_MANAGE_TOKENS_ENABLED" to true,
"ASSETS_DISCOVERY_ENABLED" to true,
"VISA_ONBOARDING_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true,
"APP_REDESIGN_ENABLED" to true,
)
)
}

View file

@ -48,6 +48,10 @@ object TestConstants {
const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val REFERRAL_API_SCENARIO = "referral_api"
const val QUOTES_API_SCENARIO = "quotes_api"
const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api"
const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api"
const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api"
const val PROVIDERS_API_SCENARIO = "networks_providers"
const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk"
const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " +
@ -60,6 +64,9 @@ object TestConstants {
"bread much nature basic fun iron benefit egg error prosper"
const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash"
const val SEED_PHRASE_HAPPY_PATH =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility"
const val TANGEM_PAY_ACCESS_CODE = "517384"
}

View file

@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? {
private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull()
val multiplier = when {
text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0
text.contains('B', ignoreCase = true) -> 1_000_000_000.0
text.contains('M', ignoreCase = true) -> 1_000_000.0
text.contains('K', ignoreCase = true) -> 1_000.0
else -> 1.0
}
val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null
return number * multiplier
}

View file

@ -11,6 +11,11 @@ fun KNode.clickWithAssertion() {
performClick()
}
fun KNode.clickWhenEnabled() {
assertIsEnabled()
performClick()
}
fun KNode.assertTextContainsSafe(
text: String,
substring: Boolean = false,

View file

@ -4,8 +4,8 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R
import io.github.kakaocup.kakao.common.utilities.getResourceString
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_SHORT
fun BaseTestCase.swipeVertical(
direction: SwipeDirection,
@ -31,21 +31,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) {
)
}
fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
val searchBarText = device.uiDevice
.findObject(By.textContains(getResourceString(R.string.markets_search_header_title)))
val bounds = searchBarText.visibleBounds
val centerX = bounds.centerX()
val startY = bounds.centerY()
val endY = when (direction) {
SwipeDirection.UP -> 50
SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100
}
device.uiDevice.swipe(centerX, startY, centerX, endY, 100)
}
fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.waitForIdle()
@ -113,6 +98,12 @@ fun BaseTestCase.restartApp(packageName: String) {
waitForIdle()
}
fun BaseTestCase.clickOnSystemButton(buttonName: String) {
device.uiDevice.wait(Until.hasObject(By.text(buttonName)), WAIT_UNTIL_TIMEOUT_SHORT)
device.uiDevice.findObject(By.text(buttonName))?.click()
?: throw AssertionError("System '$buttonName' button not found")
}
enum class SwipeDirection {
UP, DOWN
}

View file

@ -0,0 +1,20 @@
package com.tangem.common.utils
/**
* Helper for inspecting individual nodes of a BIP-44-style derivation path string
* (e.g. one read from `Network.derivationPath` of a token in the domain account model).
*/
object DerivationPathHelper {
/**
* Returns the [index1Based]-th node of a derivation path, ignoring the leading `m`.
* For "m/44'/0'/1'/0/0": node 1 = "44'", node 3 = "1'", node 5 = "0".
*/
fun nodeAt(derivationPath: String, index1Based: Int): String {
val nodes = derivationPath.removePrefix("m/").split("/")
require(index1Based in 1..nodes.size) {
"Node #$index1Based is out of range for path '$derivationPath' (${nodes.size} nodes)"
}
return nodes[index1Based - 1]
}
}

View file

@ -1,14 +1,25 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.account.Account
import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.accounts.onAccountInfoEditorScreen
import com.tangem.screens.accounts.onArchivedAccountsScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onWalletSettingsScreen
import com.tangem.utils.logging.TangemLogger
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
private const val ACCOUNT_POLL_INTERVAL_MS = 500L
fun BaseTestCase.openWalletSettingsScreen() {
step("Open 'Wallet details' screen") {
@ -19,6 +30,15 @@ fun BaseTestCase.openWalletSettingsScreen() {
}
}
fun BaseTestCase.startAccountCreation() {
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert 'Account info editor' screen is displayed") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
}
fun BaseTestCase.openAccountDetails(accountName: String) {
step("Click on account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() }
@ -28,6 +48,46 @@ fun BaseTestCase.openAccountDetails(accountName: String) {
}
}
fun BaseTestCase.checkUnsavedChangesCreationModal() {
step("Assert 'Unsaved changes' alert is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert 'Unsaved changes' alert has proper title") {
onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) }
}
step("Assert 'Unsaved changes' alert has proper description for account creation") {
onDialog {
text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_create))
}
}
step("Assert 'Keep editing' button is displayed in alert with proper text") {
onDialog { keepEditButton.assertIsDisplayed() }
}
step("Assert 'Discard' button is displayed in alert") {
onDialog { discardButton.assertIsDisplayed() }
}
}
fun BaseTestCase.assertUnsavedChangesEditionModal() {
step("Assert 'Unsaved changes' alert is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert 'Unsaved changes' alert has proper title") {
onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) }
}
step("Assert 'Unsaved changes' alert has proper description for account creation") {
onDialog {
text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_edit))
}
}
step("Assert 'Keep editing' button is displayed in alert with proper text") {
onDialog { keepEditButton.assertIsDisplayed() }
}
step("Assert 'Discard' button is displayed in alert") {
onDialog { discardButton.assertIsDisplayed() }
}
}
fun BaseTestCase.archiveAccount() {
step("Assert 'Archive' button is displayed") {
onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() }
@ -99,4 +159,53 @@ fun BaseTestCase.restoreArchivedAccount(accountName: String) {
.restoreButton.clickWithAssertion()
}
}
}
}
/**
* Polls [singleAccountListSupplier] for the selected wallet until a [Account.CryptoPortfolio] with the given
* [derivationIndex] appears with a non-empty token list, then returns it.
*
* Per-account token derivation paths live in the domain account model
* ([Account.CryptoPortfolio.cryptoCurrencies] [com.tangem.domain.models.network.Network.derivationPath]),
* not in the tester-menu "Addresses info" (which reads from the account-agnostic wallet managers store and
* only ever shows main/base derivations). Reading the model directly is the reliable source for asserting
* per-account derivations.
*/
fun BaseTestCase.awaitCryptoPortfolioAccount(derivationIndex: Int): Account.CryptoPortfolio {
val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
?: error("No selected wallet found")
var account: Account.CryptoPortfolio? = null
runBlocking {
withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
while (true) {
val candidate = singleAccountListSupplier.getSyncOrNull(walletId)
?.accounts
?.filterIsInstance<Account.CryptoPortfolio>()
?.firstOrNull { it.derivationIndex.value == derivationIndex }
if (candidate != null && candidate.cryptoCurrencies.isNotEmpty()) {
TangemLogger.i(
"Account with derivation index $derivationIndex resolved: " +
"${candidate.cryptoCurrencies.size} token(s)",
)
account = candidate
return@withTimeout
}
delay(ACCOUNT_POLL_INTERVAL_MS)
}
}
}
return requireNotNull(account) {
"Account with derivation index $derivationIndex was not found for wallet $walletId"
}
}
/**
* Returns all derivation paths of tokens whose name equals [tokenName] (case-insensitive) within this account.
*/
fun Account.CryptoPortfolio.derivationPathsForToken(tokenName: String): List<String> = cryptoCurrencies
.filter { it.name.equals(tokenName, ignoreCase = true) }
.mapNotNull { it.network.derivationPath.value }

View file

@ -175,7 +175,10 @@ fun BaseTestCase.openDeviceSettingsScreen() {
onDetailsScreen { walletNameButton.performClick() }
}
step("Click on 'Device settings' button") {
onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() }
onWalletSettingsScreen {
scrollToDeviceSettings()
deviceSettingsButton.clickWithAssertion()
}
}
}

View file

@ -6,32 +6,12 @@ import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.checkSingleCurrencyMainScreen(
cardBlockchain: String,
cardTitle: String,
withTransactions: Boolean = false,
withWalletImage: Boolean = true
) {
fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
}
if (withWalletImage) {
step("Assert card image is displayed") { //TODO: create assertion method for checking images
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert 'Receive' button is displayed") {
onMainScreen { receiveButton.assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onMainScreen { sendButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() }
@ -39,64 +19,20 @@ fun BaseTestCase.checkSingleCurrencyMainScreen(
step("Assert 'Swap' button is not displayed") {
onMainScreen { swapButton.assertIsNotDisplayed() }
}
step("Assert 'Market Price' on single card main screen is displayed") {
onMainScreen { marketPriceBlock().assertIsDisplayed() }
}
step("Assert 'Market Price' title equals $cardBlockchain Market Price") {
onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
}
if (withTransactions) {
step("Assert 'Transactions' block is displayed") {
onMainScreen { transactionsExplorerText.assertIsDisplayed() }
}
step("Assert 'Transactions' title is displayed") {
onMainScreen { transactionsTitle.assertIsDisplayed() }
}
step("Assert 'Explorer' icon is displayed") {
onMainScreen { transactionsExplorerIcon.assertIsDisplayed() }
}
} else {
step("Assert empty 'Transactions' block is displayed") {
onMainScreen { emptyTransactionBlock.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block icon is displayed") {
onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block text is displayed") {
onMainScreen { emptyTransactionBlockText.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block 'Explore' button is displayed") {
onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() }
}
}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() }
}
}
fun BaseTestCase.checkMultiCurrencyMainScreen(
devicesCount: String,
cardTitle: String,
withWalletImage: Boolean = true
) {
step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
}
if (withWalletImage) {
step("Assert card image is displayed") {
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert devices count equal to '$devicesCount'") {
onMainScreen { walletDevicesCount.assertTextContains(devicesCount) }
}
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}

View file

@ -5,6 +5,12 @@ import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.onDeviceSettingsScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.scanCardInDeviceSettings() {
step("Click on 'Scan card or ring' button") {
onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() }
}
}
fun BaseTestCase.openResetCardScreen(withBackup: Boolean = false) {
step("Click on 'Scan card or ring' button") {
onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() }

View file

@ -0,0 +1,139 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
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.clickWithAssertion
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.screens.*
import io.qameta.allure.kotlin.Allure.step
/**
* From the recipient step: fill the address and advance to the 'Send confirm' screen.
* Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in extensions on [BaseTestCase].
*/
fun BaseTestCase.enterRecipientAndOpenSendConfirm(recipientAddress: String) {
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) }
}
step("Click on 'Next' button until 'Send confirm' screen opens") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
}
}
/** Enter the send amount, then fill the recipient and open the 'Send confirm' screen. */
fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
step("Type '$amount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amount)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
enterRecipientAndOpenSendConfirm(recipientAddress)
}
/**
* On the 'Send confirm' screen, open the network-fee selector and switch the fee token from the
* native coin to the given (stablecoin) token the core gasless action repeated across the suite.
*/
fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) {
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
feeSelectorBlock.performClick()
}
}
step("Click on '$coinName' fee token to open 'Choose token'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(coinName).performClick() } }.isSuccess
}
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
/**
* Open an existing hot wallet (gasless signing needs a hot wallet, not the mock card), set the
* portfolio and quotes mocks, and reach the send amount input for the given token.
*/
fun BaseTestCase.openGaslessSendScreenWithHotWallet(
seedPhrase: String,
tokenName: String,
userTokensState: String,
quotesState: String,
) {
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("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}
/**
* Open an existing hot wallet, select the token to send and choose the swap target token/network
* the shared entry into the gasless send-via-swap flow. Scenario states stay in the test body.
*/
fun BaseTestCase.openSendViaSwapScreenWithHotWallet(
seedPhrase: String,
tokenName: String,
swapTokenName: String,
networkName: String,
networkType: String? = null,
) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Select '$swapTokenName' as the token to receive via swap") {
selectTokenToSendViaSwap(
swapTokenName = swapTokenName,
networkName = networkName,
networkType = networkType,
)
}
}
/**
* Send-via-swap amount entry: type the amount, advance past the quote-gated 'Next' button (waiting
* until it becomes enabled once the swap quote loads), then fill the recipient and open the
* 'Send confirm' screen. Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in
* extensions on [BaseTestCase].
*/
fun BaseTestCase.enterSwapAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
step("Type amount '$amount' in input field") {
onSendScreen { amountInputTextField.performTextReplacement(amount) }
}
step("Click on 'Next' button") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendScreen {
nextButton.assertIsEnabled()
nextButton.performClick()
}
}.isSuccess
}
}
enterRecipientAndOpenSendConfirm(recipientAddress)
}

View file

@ -1,28 +1,28 @@
package com.tangem.scenarios
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) {
fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) {
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
step("Click on 'Search' placeholder") {
onMarketsScreen { searchThroughMarketPlaceholder.performClick() }
}
step("Click on $blockchainName blockchain") {
waitForIdle()
onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() }
}
step("Click on $tokenName token") {
step("Click on 'In your portfolio' block") {
waitForIdle()
onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() }
}
step("Click on $tokenName token in 'Your portfolio' bottom sheet") {
waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
}
@ -54,11 +54,12 @@ fun BaseTestCase.openMarketsScreen() {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
}
@OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
openMarketsScreen()
if (shouldClickSeeAllButton)
@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
step("Scroll to 'Listed on exchanges' block") {
onMarketsScreen { scrollToListedOnBlock() }
}
step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() }

View file

@ -6,11 +6,14 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
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.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openSendScreen(
@ -34,8 +37,11 @@ fun BaseTestCase.openSendScreen(
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}
@ -91,11 +97,11 @@ fun BaseTestCase.openSendAddressScreen(
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$inputAmount' in input text field") {
onSendScreen {
@ -109,6 +115,13 @@ fun BaseTestCase.openSendAddressScreen(
step("Assert 'Send Address' container is displayed") {
onSendAddressScreen { container.assertIsDisplayed() }
}
step("Wait for recipient list to load") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}.isSuccess
}
}
}
fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) {
@ -239,13 +252,100 @@ fun BaseTestCase.checkSendViaSwapSuccessScreen() {
}
}
/** From the token details screen, open the transfer bottom sheet and reach the send amount input. */
fun BaseTestCase.openSendFromTokenDetails() {
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}
/** Open an existing hot wallet and reach the send amount input for [tokenName]. */
fun BaseTestCase.openSendScreenWithHotWallet(seedPhrase: String, tokenName: String) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
openSendFromTokenDetails()
}
fun BaseTestCase.getNetworkFeeAmount(): String {
var fee = ""
step("Read current network fee amount") {
onSendConfirmScreen { fee = feeAmount.extractText() }
}
return fee
}
fun BaseTestCase.switchFeeToFastAndApply() {
val fastOption = getResourceString(R.string.common_fee_selector_option_fast)
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
// Selecting a non-custom speed auto-applies and closes the fee selector — no 'Done' step.
step("Click on '$fastOption' fee option") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastOption).performClick() }
}
}
fun BaseTestCase.assertNetworkFeeChanged(previousFee: String) {
step("Assert network fee changed from '$previousFee'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
var current = previousFee
onSendConfirmScreen { current = feeAmount.extractText() }
current != previousFee
}.getOrDefault(false)
}
}
}
/** Reads the network fee amount on the 'Send confirm' screen (empty while it shows a loading shimmer). */
fun BaseTestCase.readNetworkFeeAmount(): String {
var fee = ""
onSendConfirmScreen { fee = feeAmount.extractText() }
return fee
}
/**
* Wait until the network fee value stops changing across two checks the send button stays disabled
* (and the hold-to-confirm gesture is swallowed) until the fee re-fetch settles. The hold button has
* no enabled/disabled semantics, so waiting on the fee value is the only reliable readiness signal.
*/
fun TestContext<Unit>.waitUntilNetworkFeeIsStable(readFee: () -> String) {
step("Wait for the network fee to finish loading") {
var previousFee: String? = null
flakySafely(timeoutMs = WAIT_UNTIL_TIMEOUT_LONG, intervalMs = FEE_STABILITY_INTERVAL_MS) {
val currentFee = readFee()
val isStable = currentFee.isNotEmpty() && currentFee == previousFee
previousFee = currentFee
if (!isStable) throw AssertionError("Network fee is still settling (current='$currentFee')")
}
}
}
private const val FEE_STABILITY_INTERVAL_MS = 750L
fun BaseTestCase.assertNetworkFeeContains(currencySymbol: String) {
step("Assert network fee contains '$currencySymbol'") {
onSendConfirmScreen { feeAmount.assertTextContains(currencySymbol, substring = true) }
}
}
fun BaseTestCase.selectTokenToSendViaSwap(
swapTokenName: String,
networkName: String,
networkType: String? = null,
) {
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Click on 'Swap to another token' button") {
onSendScreen { swapToAnotherTokenButton.performClick() }

View file

@ -10,6 +10,7 @@ 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.extensions.assertVisibility
import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.core.ui.R as CoreUiR
@ -43,8 +44,8 @@ fun BaseTestCase.openSwapScreen(
}
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() }
}
onTokenDetailsScreen { swapButton.clickWhenEnabled() }
}
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddFundsBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val buyButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val receiveButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -11,7 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
ComposeScreen<AddTokenBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
@ -23,6 +26,12 @@ class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_add))
useUnmergedTree = true
}
val laterButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_later))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =

View file

@ -0,0 +1,30 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AppCurrencySelectorScreenTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import androidx.compose.ui.test.hasText as withText
class AppCurrencySelectorPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AppCurrencySelectorPageObject>(semanticsProvider = semanticsProvider) {
val searchActionButton: KNode = child {
hasTestTag(AppCurrencySelectorScreenTestTags.TOP_BAR_ACTION_BUTTON)
}
val searchField: KNode = child {
hasTestTag(AppCurrencySelectorScreenTestTags.SEARCH_FIELD)
}
fun currencyItem(code: String): KNode = child {
hasTestTag(AppCurrencySelectorScreenTestTags.CURRENCY_ITEM)
hasAnyDescendant(withText(code, substring = true))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAppCurrencySelectorScreen(function: AppCurrencySelectorPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,20 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AppSettingsScreenTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class AppSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AppSettingsPageObject>(semanticsProvider = semanticsProvider) {
val currencyButton: KNode = child {
hasTestTag(AppSettingsScreenTestTags.CURRENCY_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAppSettingsScreen(function: AppSettingsPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -2,29 +2,38 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
/**
* "You receive" token chooser opened from the main-screen "Add funds" button.
* Token chooser bottom sheet opened from the main-screen "Add funds" button.
*
* After the onramp redesign this is a [BaseBottomSheetTestTags.CONTAINER] bottom sheet
* (centered title + close icon), not a full screen with a top app bar.
*/
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenPageObject>(semanticsProvider = semanticsProvider) {
ComposeScreen<ChooseTokenPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(CoreResR.string.common_add_funds))
useUnmergedTree = true
}
val searchBar: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
useUnmergedTree = true
}
fun tokenWithTitle(tokenTitle: String): KNode = child {

View file

@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DetailsPageObject>(semanticsProvider = semanticsProvider) {
@ -22,17 +23,9 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.wallet_connect_title))
}
private val walletBlock: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
}
val walletNameButton: KNode = walletBlock.child {
hasClickAction()
hasPosition(0)
}
val scanCardButton: KNode = walletBlock.child {
hasText(getResourceString(R.string.scan_card_settings_button))
val walletNameButton: KNode = child {
hasTestTag(DetailsScreenTestTags.USER_WALLET_ITEM)
useUnmergedTree = true
}
val buyTangemButton: KNode = child {
@ -58,6 +51,12 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(DetailsScreenTestTags.VERSION_NAME)
useUnmergedTree = true
}
fun walletNameValue(name: String): KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
hasAnyDescendant(withText(name))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onDetailsScreen(function: DetailsPageObject.() -> Unit) =

View file

@ -13,6 +13,9 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasClickAction as withClickAction
import androidx.compose.ui.test.hasText as withText
import androidx.compose.ui.test.isNotEnabled as withDisabled
class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DeviceSettingsPageObject>(semanticsProvider = semanticsProvider) {
@ -46,6 +49,19 @@ class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
useUnmergedTree = true
}
val securityModeRowTitle: KNode = child {
hasTestTag(DeviceSettingsScreenTestTags.ITEM_TITLE)
hasText(getResourceString(R.string.card_settings_security_mode))
useUnmergedTree = true
}
// Match the row container (not the title Text): enabled exposes a click action, disabled exposes disabled semantics.
val securityModeRow: KNode = child {
addSemanticsMatcher(withClickAction() or withDisabled())
hasAnyDescendant(withText(getResourceString(R.string.card_settings_security_mode)))
useUnmergedTree = true
}
fun resetToFactorySettingsButtonSubtitle(withBackup: Boolean = false): KNode = child {
hasTestTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE)
useUnmergedTree = true

View file

@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DialogPageObject>(semanticsProvider = semanticsProvider) {
@ -25,6 +26,17 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(BaseDialogTestTags.TEXT)
}
val inputField: KNode = child {
hasSetTextAction()
hasAnyAncestor(withTestTag(BaseDialogTestTags.TEXT_INPUT_FIELD))
useUnmergedTree = true
}
val gotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_got_it))
}
val cancelButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_cancel))
@ -45,6 +57,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.account_details_archive_action))
}
val discardButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.account_unsaved_dialog_action_second))
}
val keepEditButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.account_unsaved_dialog_action_first))
}
val continueButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_continue))

View file

@ -1,10 +1,7 @@
package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.*
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition
@ -22,7 +19,7 @@ import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR
class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainScreenPageObject>(semanticsProvider = semanticsProvider) {
private val lazyList = KLazyListNode(
@ -49,32 +46,38 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val buyButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy))
hasAnyDescendant(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
}
val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_add_funds))
hasAnyDescendant(withText(getResourceString(R.string.common_add_funds)))
useUnmergedTree = true
}
val sendButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send))
hasAnyDescendant(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
}
val receiveButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive))
hasAnyDescendant(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
}
val sellButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell))
hasAnyDescendant(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap))
hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val walletNameText: KNode = child {
@ -87,13 +90,37 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true
}
val walletDevicesCount: KNode = child {
hasTestTag(MainScreenTestTags.DEVICES_COUNT)
/**
* Collapses the collapsing header via a touch-based swipe so that items near the bottom
* of the lazy list fall within screen bounds before programmatic childWith scroll.
* Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which
* pushes lower list items off-screen when the header is expanded.
*/
private fun collapseHeader() {
screenContainer {
performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) }
}
}
val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true
}
val walletImportedBanner: KNode = child {
hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)
useUnmergedTree = true
}
val walletImportedBannerCheckHereButton: KNode = child {
hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER))
hasText(getResourceString(CoreResR.string.main_manage_tokens))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun marketPriceBlock(): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MarketPriceBlockTestTags.BLOCK)
useUnmergedTree = true
@ -225,6 +252,22 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true
}
/**
* Empty-tokens placeholder shown under an expanded account that has no tokens.
*/
val emptyAccountTokensPlaceholder: KNode = child {
hasTestTag(MainScreenTestTags.EMPTY_TOKENS_PLACEHOLDER)
useUnmergedTree = true
}
/**
* 'Add tokens' button inside the empty-account placeholder. Click opens manage tokens for that account.
*/
val emptyAccountAddTokensButton: KNode = child {
hasTestTag(MainScreenTestTags.EMPTY_TOKENS_ADD_BUTTON)
useUnmergedTree = true
}
/**
* Main account header on the main screen. Click to expand/collapse its tokens list.
*/
@ -236,6 +279,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
*/
@OptIn(ExperimentalTestApi::class)
fun accountWithName(name: String): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(name))
@ -243,11 +287,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
}
@OptIn(ExperimentalTestApi::class)
fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
}
/**
* Find token list item with title and address
*/
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndAddress(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
@ -260,6 +314,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class)
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
@ -272,6 +327,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class)
fun addAndManageButton(): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
}.child<KNode> {
@ -287,11 +343,17 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
val marketsSheetDragHandle: KNode = child {
hasTestTag(MainScreenTestTags.MARKETS_SHEET_DRAG_HANDLE)
useUnmergedTree = true
}
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
collapseHeader()
return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyChild(withText(tokenNetwork))
@ -301,6 +363,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
@ -312,6 +375,46 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
}
/**
* Account row on the main screen. Tappable click to expand/collapse its tokens.
*/
@OptIn(ExperimentalTestApi::class)
fun findAccountSectionByName(accountName: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
/**
* Scrolls the account row into view and collapses the top bar so the account's tokens (or the
* empty placeholder) land within screen bounds after expansion. Click via [findAccountSectionByName].
*/
@OptIn(ExperimentalTestApi::class)
fun scrollToAccountSection(accountName: String) {
collapseHeader()
lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
/**
* Find a token row on the main screen by token name. Tokens belonging to collapsed accounts
* are hidden from the semantics tree, so expanding a single account before calling this
* effectively scopes the lookup to that account's tokens.
*/
@OptIn(ExperimentalTestApi::class)
fun findTokenInAnyAccountByName(tokenName: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenName))
useUnmergedTree = true
}
}
fun KNode.assertIsUnreachable() {
this {
hasAnyAncestor(withText(getResourceString(R.string.common_unreachable)))
@ -324,16 +427,17 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
* Tests will fail if assertIsNotDisplayed() or assertDoesNotExist() are used instead.
*/
fun assertTokenDoesNotExist(tokenTitle: String) {
try {
tokenWithTitleAndAddress(tokenTitle).assertExists()
throw AssertionError("Token with title '$tokenTitle' should not exist but was found")
} catch (e: AssertionError) {
if (e.message?.contains("No node found") == true) {
return
} else {
throw e
}
}
lazyList.child<KNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenTitle))
useUnmergedTree = true
}.assertDoesNotExist()
}
fun assertTokensCount(expectedCount: Int) {
semanticsProvider
.onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE))
.assertCountEquals(expectedCount)
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.ManageTokensScreenTestTags
import com.tangem.core.ui.test.SwitchTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
@ -20,6 +21,16 @@ import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(com.tangem.core.ui.R.string.add_tokens_title))
useUnmergedTree = true
}
val searchField: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}

View file

@ -2,11 +2,9 @@ package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions
fun allExchangeTypeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))))
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))
.fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)))
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))
.fetchSemanticsNodes()
val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true
}

View file

@ -1,6 +1,8 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX
import com.tangem.core.ui.test.BaseButtonTestTags
@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) {
val addToPortfolioButton: KNode = child {
val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add_to_portfolio))
hasText(getResourceString(R.string.common_add))
useUnmergedTree = true
}
@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
val tokenDetailsContent: KNode = child {
hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT)
useUnmergedTree = true
}
@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
}
val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true)
hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)
useUnmergedTree = true
}
val listedOnEmptyText: KNode = child {
@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(title)
}
}
@ExperimentalTestApi
fun scrollToListedOnBlock() {
tokenDetailsContent {
performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK))
}
}
}
internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) =

View file

@ -3,14 +3,13 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.ui.R as CoreUiR
class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) {
@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_swap), substring = true)
}
val inYourPortfolioBlock: KNode = child {
hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true)
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode = child {
hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM))
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON))
hasAnyChild(withText(title))
hasClickAction()
useUnmergedTree = true
}
}

View file

@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
useUnmergedTree = true
}
private val topBarGroupButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON)
val organizeMenuButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON)
useUnmergedTree = true
}
val groupButton: KNode = topBarGroupButton.child {
val groupButton: KNode = child {
hasText(getResourceString(R.string.organize_tokens_group))
useUnmergedTree = true
}
val ungroupButton: KNode = topBarGroupButton.child {
hasText(getResourceString(R.string.organize_tokens_ungroup))
useUnmergedTree = true
}
val sortByBalanceButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON)
hasText(getResourceString(R.string.organize_tokens_sort_by_balance))
useUnmergedTree = true
}
// endregion TopBar
@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
return lazyList.child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM)
hasAnyChild(withText(tokenNetwork))
hasAnyDescendant(withText(tokenNetwork))
useUnmergedTree = true
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class SecurityModePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SecurityModePageObject>(semanticsProvider = semanticsProvider) {
// Description only appears on the Security Mode screen — unambiguous "screen opened" signal.
val longTapOptionDescription: KNode = child {
hasText(getResourceString(R.string.details_manage_security_long_tap_description))
useUnmergedTree = true
}
val saveChangesButton: KNode = child {
hasText(getResourceString(R.string.common_save_changes))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSecurityModeScreen(function: SecurityModePageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -6,7 +6,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.FooterTestTags
import com.tangem.core.ui.test.SendAddressScreenTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
): KNode = child {
hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM)
hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON))
hasAnyDescendant(withText(recipientAddress))
hasAnyDescendant(withText(recipientAddress, substring = true))
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT))
useUnmergedTree = true
if (description != null) {

View file

@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true
}
fun warningMessageContaining(textPart: String): KNode = child {
hasTestTag(NotificationTestTags.MESSAGE)
hasText(textPart, substring = true)
useUnmergedTree = true
}
fun warningTitleContaining(textPart: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(textPart, substring = true)
useUnmergedTree = true
}
fun warningIcon(message: String): KNode = child {
hasTestTag(NotificationTestTags.ICON)
hasAnySibling(withText(message))
@ -145,6 +157,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true
}
fun feeBlockCurrency(symbol: String): KNode = child {
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
hasAnyDescendant(withText(symbol))
useUnmergedTree = true
}
val refreshButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreUiR.string.warning_button_refresh))

View file

@ -0,0 +1,65 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
/**
* Gasless fee selector modal: the `NetworkFee` route (fee-paying token row + selected speed) and the
* `ChooseToken` route. The `ChooseSpeed` route is covered by [SendSelectNetworkFeeBottomSheetPageObject].
*/
class SendFeeSelectorBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SendFeeSelectorBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val networkFeeTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_network_fee_title))
useUnmergedTree = true
}
val chooseTokenTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.fee_selector_choose_token_title))
useUnmergedTree = true
}
val feeTokenRow: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
useUnmergedTree = true
}
fun feeTokenItem(tokenName: String): KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnyChild(withText(tokenName))
useUnmergedTree = true
}
fun feeSpeedItemTitle(speed: String): KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE)
hasText(speed)
useUnmergedTree = true
}
val applyButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_apply)))
useUnmergedTree = true
}
val notEnoughFundsError: KNode = child {
hasText(getResourceString(R.string.gasless_not_enough_funds_to_cover_token_fee))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSendFeeSelectorBottomSheet(function: SendFeeSelectorBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -9,7 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import com.tangem.features.send.v2.impl.R as SendR
import com.tangem.features.send.impl.R as SendR
import androidx.compose.ui.test.hasText as withText
class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :

View file

@ -1,20 +1,15 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import com.tangem.features.tokendetails.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
@ -36,18 +31,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
val availableStakingBlockTitle: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE)
useUnmergedTree = true
}
val availableStakingBlockText: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT)
useUnmergedTree = true
}
val availableStakingBlockCurrencyIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON)
fun availableStakingBlockText(apy: String): KNode = child {
hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy))
useUnmergedTree = true
}
@ -62,69 +47,44 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
val stakingDot: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT)
useUnmergedTree = true
}
val stakingTokenAmount: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT)
useUnmergedTree = true
}
val stakingChevronIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON)
useUnmergedTree = true
val stakingTitle: KNode = child {
hasText(getResourceString(R.string.common_staking))
}
val stakingTitle: KNode = child {
hasText(getResourceString(R.string.staking_native))
val stakingEnabledTitle: KNode = child {
hasText(getResourceString(R.string.staking_enabled))
}
val title: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE)
}
private val horizontalActionChips = KLazyListNode(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) },
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position ->
SemanticsMatcher.expectValue(
LazyListItemPositionSemantics,
position
)
}
)
@OptIn(ExperimentalTestApi::class)
fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive))
val fiatBalance: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT)
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun swapButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap))
hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds)))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun sellButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
val swapButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell))
hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun buyButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
val transferButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy))
}
@OptIn(ExperimentalTestApi::class)
fun sendButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send))
hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
useUnmergedTree = true
}
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
@ -204,7 +164,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON))
useUnmergedTree = true
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TransferBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val sendButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val sellButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,37 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import androidx.compose.ui.test.hasText as withText
class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TxHistoryPageObject>(semanticsProvider = semanticsProvider) {
fun transactionItem(title: String): KNode = child {
hasTestTag(TransactionHistoryItemTestTags.ITEM)
hasAnyDescendant(withText(title))
useUnmergedTree = true
}
fun transactionAmount(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.AMOUNT)
useUnmergedTree = true
}
fun transactionCurrency(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.CURRENCY)
useUnmergedTree = true
}
fun transactionConfirmedStatus(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -1,5 +1,6 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TopAppBarTestTags
@ -9,11 +10,16 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<WalletSettingsPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER)
}
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
@ -22,6 +28,31 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
hasTestTag(WalletSettingsScreenTestTags.SCREEN_ITEM)
}
// The Accounts section loads async and can push rows below the fold — scroll before asserting/clicking.
private val scrollableContainer: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER)
}
@OptIn(ExperimentalTestApi::class)
fun scrollToText(text: String) = scrollableContainer { performScrollToNode(withText(text)) }
@OptIn(ExperimentalTestApi::class)
fun scrollToDeviceSettings() = scrollToText(getResourceString(R.string.card_settings_title))
@OptIn(ExperimentalTestApi::class)
fun scrollToLinkMoreCards() = scrollToText(getResourceString(R.string.details_row_title_create_backup))
@OptIn(ExperimentalTestApi::class)
fun scrollToReferralProgram() = scrollToText(getResourceString(R.string.details_referral_title))
@OptIn(ExperimentalTestApi::class)
fun scrollToForgetWallet() = scrollToText(getResourceString(R.string.settings_forget_wallet))
@OptIn(ExperimentalTestApi::class)
fun scrollToRenameButton() = scrollableContainer {
performScrollToNode(withTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON))
}
val linkMoreCardsButton: KNode = walletSettingsItem.child {
hasText(getResourceString(R.string.details_row_title_create_backup))
}
@ -38,6 +69,16 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
hasText(getResourceString(R.string.settings_forget_wallet))
}
val renameWalletButton: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON)
useUnmergedTree = true
}
fun walletNameValue(name: String): KNode = walletSettingsItem.child {
hasText(name)
useUnmergedTree = true
}
val accountsListContainer: KNode = walletSettingsItem.child {
hasTestTag(WalletSettingsScreenTestTags.ACCOUNTS_CONTAINER)
}

View file

@ -0,0 +1,45 @@
package com.tangem.screens.accounts
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.accounts.AccountInfoEditScreenTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class AccountInfoEditPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AccountInfoEditPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.ACCOUNT_DETAILS_CONTAINER)
}
val accountNameField: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.NAME_FIELD)
}
val accountCurrentIcon: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.SELECTED_ICON)
}
val accountColorOption: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.COLOR_OPTION)
}
val accountTypeOption: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.TYPE_OPTION)
}
val saveAccountButton: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.SAVE_ACCOUNT_BUTTON)
}
val crossButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
}
internal fun BaseTestCase.onAccountInfoEditorScreen(function: AccountInfoEditPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,81 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class AppCurrencyTest : BaseTestCase() {
@AllureId("781")
@DisplayName("App Currency: change of equivalent")
@Test
fun changeAppCurrencyTest() {
val currenciesScenario = "currencies_api"
val appSettingsState = "AppSettings"
val targetCurrency = "EUR"
val targetSymbol = ""
val token = "Bitcoin"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(currenciesScenario) },
).run {
step("Set WireMock scenario '$currenciesScenario' to '$appSettingsState'") {
setWireMockScenarioState(scenarioName = currenciesScenario, state = appSettingsState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
synchronizeAddresses()
step("Open wallet details") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Click on 'App settings' button") {
onDetailsScreen { appSettingsButton.clickWithAssertion() }
}
step("Click on 'App currency' button") {
onAppSettingsScreen { currencyButton.clickWithAssertion() }
}
step("Click on search button") {
onAppCurrencySelectorScreen { searchActionButton.clickWithAssertion() }
}
step("Search currency '$targetCurrency'") {
onAppCurrencySelectorScreen { searchField.performTextInput(targetCurrency) }
}
step("Click on currency '$targetCurrency'") {
onAppCurrencySelectorScreen { currencyItem(targetCurrency).performClick() }
}
step("Press 'Back' button to return to 'Details' screen") {
waitForIdle()
device.uiDevice.pressBack()
}
step("Press 'Back' button to return to 'Main' screen") {
waitForIdle()
device.uiDevice.pressBack()
}
step("Assert total balance contains '$targetSymbol' on 'Main' screen") {
// Balance re-loads in the new currency async after the switch — wait for the € equivalent.
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { totalBalanceText.assertTextContains(targetSymbol, substring = true) }
}
}
step("Click on token '$token'") {
onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() }
}
step("Assert token fiat balance contains '$targetSymbol'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { fiatBalance.assertTextContains(targetSymbol, substring = true) }
}
}
}
}
}

View file

@ -5,14 +5,21 @@ import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openMainScreen
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.content.Firmware412MockContent
import com.tangem.tap.domain.sdk.mocks.content.S2CMockContent
import com.tangem.tap.domain.sdk.mocks.content.SingleCurrencyMockContent
import com.tangem.tap.domain.sdk.mocks.content.V3MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
class DetailsTest : BaseTestCase() {
@AllureId("836")
@DisplayName("Details: (Wallet) fields")
@Test
fun walletWithoutBackupDetailsTest() =
setupHooks().run {
@ -46,70 +53,26 @@ class DetailsTest : BaseTestCase() {
}
onWalletSettingsScreen {
step("Assert 'Link more cards' button is visible") {
scrollToLinkMoreCards()
linkMoreCardsButton.assertIsDisplayed()
}
step("Assert 'Card Settings' button is visible") {
scrollToDeviceSettings()
deviceSettingsButton.assertIsDisplayed()
}
step("Assert 'Referral program' button is visible") {
scrollToReferralProgram()
referralProgramButton.assertIsDisplayed()
}
step("Assert 'Forget wallet' button is visible") {
scrollToForgetWallet()
forgetWalletButton.assertIsDisplayed()
}
}
}
// @Test
fun wallet2DetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Wallet2)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is visible") {
walletConnectButton.assertIsDisplayed()
}
step("Assert 'Scan card' button is visible") {
scanCardButton.assertIsDisplayed()
}
step("Assert 'Buy Tangem card' button is visible") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is visible") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is visible") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms or service' button is visible") {
toSButton.assertIsDisplayed()
}
step("Open 'Wallet settings' screen") {
walletNameButton.clickWithAssertion()
}
}
onWalletSettingsScreen {
step("Assert 'Link more cards' button does not exist") {
linkMoreCardsButton.assertIsNotDisplayed()
}
step("Assert 'Card Settings' button is visible") {
deviceSettingsButton.assertIsDisplayed()
}
step("Assert 'Referral program' button is visible") {
referralProgramButton.assertIsDisplayed()
}
step("Assert 'Forget wallet' button is visible") {
forgetWalletButton.assertIsDisplayed()
}
}
}
@AllureId("837")
@DisplayName("Details: (Note) fields")
@Test
fun noteDetailsTest() =
setupHooks().run {
@ -154,6 +117,214 @@ class DetailsTest : BaseTestCase() {
}
}
@AllureId("840")
@DisplayName("Details: (Twins) fields")
@Test
fun twinsDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Twins, isTwinsCard = true)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button does not exist") {
walletConnectButton.assertIsNotDisplayed()
}
step("Assert 'Buy Tangem card' button is visible") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is visible") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is visible") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is visible") {
toSButton.assertIsDisplayed()
}
step("Assert app version is visible") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("839")
@DisplayName("Details: (v4.12) fields")
@Test
fun firmware412DetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = Firmware412MockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is visible") {
walletConnectButton.assertIsDisplayed()
}
step("Assert 'Buy Tangem card' button is visible") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is visible") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is visible") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is visible") {
toSButton.assertIsDisplayed()
}
step("Assert app version is visible") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("838")
@DisplayName("Details: (v3 multicurrency) fields")
@Test
fun v3MultiCurrencyDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = V3MockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is displayed") {
walletConnectButton.assertIsDisplayed()
}
step("Assert 'Buy Tangem card' button is displayed") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is displayed") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is displayed") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is displayed") {
toSButton.assertIsDisplayed()
}
step("Assert app version is displayed") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("9832")
@DisplayName("Details: (single currency) fields")
@Test
fun singleCurrencyDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = SingleCurrencyMockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is not displayed") {
walletConnectButton.assertIsNotDisplayed()
}
step("Assert 'Buy Tangem card' button is displayed") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is displayed") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is displayed") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is displayed") {
toSButton.assertIsDisplayed()
}
step("Assert app version is displayed") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("841")
@DisplayName("Details: (S2C) fields")
@Test
fun s2cDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = S2CMockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is not displayed") {
walletConnectButton.assertIsNotDisplayed()
}
step("Assert 'Buy Tangem card' button is displayed") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is displayed") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is displayed") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is displayed") {
toSButton.assertIsDisplayed()
}
step("Assert app version is displayed") {
versionName.assertIsDisplayed()
}
}
}
// Parked: createWalletActions adds Sell for single-wallet cards with no isStart2Coin() check.
@Ignore("[REDACTED_JIRA]")
@AllureId("2869")
@DisplayName("Details: (S2C) no trade buttons and standard details")
@Test
fun s2cNoTradeButtonsDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = S2CMockContent)
}
onMainScreen {
step("Assert 'Buy' button is not displayed") {
buyButton.assertIsNotDisplayed()
}
step("Assert 'Sell' button is not displayed") {
sellButton.assertIsNotDisplayed()
}
step("Assert 'Swap' button is not displayed") {
swapButton.assertIsNotDisplayed()
}
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is not displayed") {
walletConnectButton.assertIsNotDisplayed()
}
}
}
@AllureId("3647")
@DisplayName("Referral program: validate screen")
@Test

View file

@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen
import com.tangem.screens.onStoriesScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onTransferBottomSheet
import com.tangem.tap.domain.sdk.mocks.MockProvider
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$sendAmount' in input text field") {
onSendScreen {

View file

@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() {
fun groupTokensTest() {
setupHooks().run {
val tokenTitle = "Ethereum"
val tokenNetwork = "Ethereum network"
val networkTitleOrganize = "Ethereum"
val networkTitleMain = "Ethereum network"
step("Open 'Main Screen'") {
openMainScreen()
@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed()
}
}
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' button") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
}
step("Assert tokens were grouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() }
}
step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() }
}
step("Assert tokens were grouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() }
}
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed()
}
}
step("Click 'Ungroup' button") {
onOrganizeTokensScreen { ungroupButton.clickWithAssertion() }
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' checkbox again to ungroup") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
}
step("Assert tokens were ungrouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() }
onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() }
}
step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() }
}
step("Assert tokens were ungrouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() }
onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() }
}
}
}
@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed()
}
}
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'By Balance' button") {
onOrganizeTokensScreen {
sortByBalanceButton.clickWithAssertion()

View file

@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(cardType)
}
step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") {
checkSingleCurrencyMainScreen(
cardBlockchain = cardBlockchain,
cardTitle = cardType.name,
withTransactions = true
)
checkSingleCurrencyMainScreen(cardTitle = cardType.name)
}
}
}
@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType, isTwinsCard = true)
}
step("Check 'Main' screen for '$cardName' $cardBlockchain card") {
checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName)
checkSingleCurrencyMainScreen(cardTitle = cardName)
}
}
}
@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with Secp256k1 curve")
@Test
fun secpk1CurveCardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Secpk1CurveMockContent
val cardName = "Wallet"
val card = "card with Secp256k1 curve"
@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on $card") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(
devicesCount = devicesCount,
cardTitle = cardName,
withWalletImage = false
)
step("Check 'Main' screen for $card curve") {
checkMultiCurrencyMainScreen(cardTitle = cardName)
}
}
}
@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") {
checkSingleCurrencyMainScreen(
cardBlockchain = cardBlockchain,
cardTitle = cardName,
withWalletImage = false
)
checkSingleCurrencyMainScreen(cardTitle = cardName)
}
}
}
@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Shiba' card")
@Test
fun shibaCardScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = ShibaMockContent
val cardName = "Wallet"
val card = "Shiba"
@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Ring'")
@Test
fun ringScanTest() {
val devicesCount = "3 devices"
val cardType: ProductType = ProductType.Ring
val cardName = "Wallet"
val ring = "Ring"
@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$ring'") {
openMainScreen(productType = cardType)
}
step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$ring'") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet' card")
@Test
fun walletCardScanTest() {
val devicesCount = "1 device"
val cardType: ProductType = ProductType.Wallet
val cardName = "Wallet"
@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$cardName' card") {
openMainScreen(productType = cardType)
}
step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$cardName' card") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet 2' card")
@Test
fun wallet2ScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = Wallet2MockContent
val cardName = "Wallet"
val card = "Wallet 2"
@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with 4.12 firmware")
@Test
fun firmware412CardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Firmware412MockContent
val cardName = "Tangem card"
val card = "card with 4.12 firmware"
@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card'") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$card'") {
checkMultiCurrencyMainScreen(cardName)
}
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openDeviceSettingsScreen
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.scanCardInDeviceSettings
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SecurityModeTest : BaseTestCase() {
@AllureId("2267")
@DisplayName("Security Mode: available for Twin cards")
@Test
fun securityModeOpensForTwinsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Twins, isTwinsCard = true)
}
step("Open 'Device settings' screen") {
openDeviceSettingsScreen()
}
step("Scan card in 'Device settings'") {
scanCardInDeviceSettings()
}
step("Assert 'Security mode' row is enabled") {
onDeviceSettingsScreen { securityModeRow.assertIsEnabled() }
}
step("Click on 'Security mode' button") {
onDeviceSettingsScreen { securityModeRow.performClick() }
}
onSecurityModeScreen {
step("Assert 'Long tap' option is displayed") {
longTapOptionDescription.assertIsDisplayed()
}
step("Assert 'Save changes' button is displayed") {
saveChangesButton.assertIsDisplayed()
}
}
}
@AllureId("9831")
@DisplayName("Security Mode: unavailable for single-capability cards")
@Test
fun securityModeRowDisabledForOtherCardsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Open 'Device settings' screen") {
openDeviceSettingsScreen()
}
step("Scan card in 'Device settings'") {
scanCardInDeviceSettings()
}
step("Assert 'Security mode' row title is displayed") {
onDeviceSettingsScreen { securityModeRowTitle.assertIsDisplayed() }
}
step("Assert 'Security mode' row is disabled") {
onDeviceSettingsScreen { securityModeRow.assertIsNotEnabled() }
}
}
}

View file

@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { stakingBlock.assertIsDisplayed() }
}
step("Assert 'Staking title' is displayed") {
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() }
}
step("Assert 'Staking fiat amount' is displayed") {
onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() }
}
step("Assert 'Staking dot' is displayed") {
onTokenDetailsScreen { stakingDot.assertIsDisplayed() }
}
step("Assert 'Staking token amount' is displayed") {
onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() }
}
step("Assert 'Staking block chevron icon' is displayed") {
onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() }
}
}
}
@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() {
val scenarioName = "staking_eth_pol_balances_android"
val scenarioState = "Started"
val stakingAmount = "1"
val stakingApy = "2.84%"
setupHooks(
additionalAfterSection = {
@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() }
}
step("Assert 'Available staking block' title is displayed") {
onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() }
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
}
step("Assert 'Available staking block' text is displayed") {
onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() }
}
step("Assert 'Available staking block' currency icon is displayed") {
onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() }
onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() }
}
step("Click on 'Stake' button") {
onTokenDetailsScreen { stakeButton.clickWithAssertion() }

View file

@ -0,0 +1,59 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.openMainScreen
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class WalletRenameTest : BaseTestCase() {
@AllureId("2264")
@DisplayName("Wallet details: rename wallet")
@Test
fun renameWalletTest() =
setupHooks().run {
val newWalletName = "Tangem QA"
step("Open 'Main Screen'") {
openMainScreen()
}
step("Open wallet details") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }
}
step("Click on 'Rename' button") {
onWalletSettingsScreen {
scrollToRenameButton()
renameWalletButton.clickWithAssertion()
}
}
step("Enter new wallet name '$newWalletName'") {
onDialog { inputField.performTextReplacement(newWalletName) }
}
step("Click on 'OK' button") {
onDialog { okButton.clickWithAssertion() }
}
step("Assert new wallet name '$newWalletName' is displayed on 'Wallet settings' screen") {
onWalletSettingsScreen { walletNameValue(newWalletName).assertIsDisplayed() }
}
step("Click on 'Back' button") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert new wallet name '$newWalletName' is displayed on 'Details' screen") {
onDetailsScreen { walletNameValue(newWalletName).assertIsDisplayed() }
}
step("Click on 'Back' button") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert new wallet name '$newWalletName' is displayed on 'Main' screen") {
onMainScreen { walletNameText.assertTextContains(newWalletName) }
}
}
}

View file

@ -1,14 +1,10 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test

View file

@ -3,6 +3,7 @@ package com.tangem.tests.accounts
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -10,7 +11,9 @@ import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.accounts.onArchivedAccountsScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onWalletSettingsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
@ -164,8 +167,8 @@ class AccountArchivationsTest : BaseTestCase() {
@Test
@AllureId("5976")
@DisplayName("Accounts: restore an archived account")
fun restoreArchivedAccountTest() {
@DisplayName("Accounts: restore a simple archived account")
fun restoreSimpleArchivedAccountTest() {
val archivedAccountName = "Account 3"
val userAccountsInitialState = "TwoAccountsWithArchivedAccounts"
val userAccountsAfterArchivationState = "ReadyToRestore"
@ -202,6 +205,108 @@ class AccountArchivationsTest : BaseTestCase() {
}
}
@Test
@AllureId("5980")
@DisplayName("Accounts: restore archived account with custom token transfer")
fun restoreArchivedAccountWithCustomTokensTest() {
val mainAccountName = "Main account"
val archivedAccountName = "Account 2"
val customTokenName = "Ethereum"
val expectedArchivedTokensInfo = "1 token"
val userAccountsInitialState = "OneAccountWithArchivedCustomToken"
val userAccountsReadyToRestoreState = "ReadyToRestoreCustomToken"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, userAccountsInitialState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() }
step("Verify archived account '$archivedAccountName' shows '$expectedArchivedTokensInfo'") {
onArchivedAccountsScreen {
val row = findArchivedAccountItemByName(archivedAccountName)
row.container.assertIsDisplayed()
row.subtitle.assertTextContains(expectedArchivedTokensInfo, substring = true)
}
}
step("Switch WireMock to '$userAccountsReadyToRestoreState'") {
setWireMockScenarioState(userTokensScenario, userAccountsReadyToRestoreState)
}
step("Click restore button for '$archivedAccountName'") {
onArchivedAccountsScreen {
findArchivedAccountItemByName(archivedAccountName)
.restoreButton.clickWithAssertion()
}
}
step("Assert custom token migration dialog is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert dialog text mentions main account '$mainAccountName'") {
onDialog { text.assertTextContains(mainAccountName, substring = true) }
}
step("Assert dialog text mentions restoring account '$archivedAccountName'") {
onDialog { text.assertTextContains(archivedAccountName, substring = true) }
}
step("Confirm migration in dialog") {
onDialog { gotItButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert restored account '$archivedAccountName' is in active accounts list") {
onWalletSettingsScreen { accountItem(archivedAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert main account '$mainAccountName' is visible on main screen") {
onMainScreen { findAccountSectionByName(mainAccountName).assertIsDisplayed() }
}
step("Assert restored account '$archivedAccountName' is visible on main screen") {
onMainScreen { findAccountSectionByName(archivedAccountName).assertIsDisplayed() }
}
step("Expand main account '$mainAccountName'") {
onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() }
}
step("Assert '$customTokenName' is NOT displayed under main account") {
onMainScreen { assertTokenDoesNotExist(customTokenName) }
}
step("Expand main account '$mainAccountName'") {
onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() }
}
step("Assert '$customTokenName' is NOT displayed under main account") {
onMainScreen {
assertTokenDoesNotExist(customTokenName)
}
}
step("Expand restored account '$archivedAccountName' and assert '$customTokenName' is displayed") {
onMainScreen {
findAccountSectionByName(archivedAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { findTokenInAnyAccountByName(customTokenName).assertIsDisplayed() }
},
)
}
}
}
}
@Test
@AllureId("7962")
@DisplayName("Accounts: restore archived account error")
@ -250,4 +355,5 @@ class AccountArchivationsTest : BaseTestCase() {
}
}
}
}

View file

@ -0,0 +1,492 @@
package com.tangem.tests.accounts
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickOnSystemButton
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.DerivationPathHelper
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setClipboardText
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountInfoEditorScreen
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.Assert.assertTrue
import org.junit.Test
@HiltAndroidTest
class AccountCreationTest : BaseTestCase() {
private val userTokensScenario = "user_tokens_api"
@Test
@AllureId("5504")
@DisplayName("Accounts: account creation network error handling")
fun accountCreationErrorTest() {
val accountName = "Account 2"
val userAccountsGetErrorState = "AccountsGetError"
val userAccountsPutErrorState = "AccountsPutError"
val userAccountsBeforeCreationState = "AccountReadyToCreate"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, userAccountsGetErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Click 'Add account' button (GET accounts is blocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onDialog { dialogContainer.assertIsDisplayed() }
},
)
}
}
step("Assert error dialog details") {
assertErrorDialog(
expectedTitle = getResourceString(R.string.common_something_went_wrong),
expectedMessage = getResourceString(com.tangem.core.ui.R.string.account_generic_error_dialog_message),
)
}
step("Dismiss error dialog") { dismissErrorDialog() }
step("Assert still on account creation screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Unblock GET accounts, block PUT accounts") {
setWireMockScenarioState(userTokensScenario, userAccountsPutErrorState)
}
step("Click 'Add account' button again (PUT accounts is blocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onDialog { dialogContainer.assertIsDisplayed() }
},
)
}
}
step("Assert still on account creation screen") {
assertErrorDialog(
expectedTitle = getResourceString(R.string.common_something_went_wrong),
expectedMessage = getResourceString(R.string.account_generic_error_dialog_message),
)
}
step("Unblock both 'accounts' requests") {
setWireMockScenarioState(userTokensScenario, userAccountsBeforeCreationState)
}
step("Dismiss error dialog") { dismissErrorDialog() }
step("Assert still on account creation screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'Add account' button again (both requests unblocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Assert 'Manage Tokens' title is displayed") {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Close 'Manage Tokens' screen") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new account '$accountName' appears in accounts list") {
onWalletSettingsScreen { accountItem(accountName).assertIsDisplayed() }
}
}
}
@Test
@AllureId("5507")
@DisplayName("Accounts: name field verifications")
fun accountsCreationNameFieldValidationTest() {
val accountName = "TestAccount12"
val longName = "A".repeat(21)
val emptyPlaceholderValue = "New account"
val editedName = "Edited"
val context = device.context
val pasteButtonName = "Paste"
setupHooks().run {
step("Set clipboard text '$longName'") {
setClipboardText(context,longName)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open 'Wallet settings' screen") { openWalletSettingsScreen() }
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert 'Edit account details' dialog screen appears") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Enter account name manually: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Assert name input is stable (keyboard doesn't flicker)") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Assert 'Add account' button is enabled") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsEnabled()
}
}
step("Clear the 'Edit name' field") {
onAccountInfoEditorScreen {
accountNameField.performTextClearance()
}
}
step("Assert 'Add account' button becomes inactive when field is empty") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Paste name from clipboard: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(accountName)
}
}
step("Assert pasted text is displayed in 'Account name' field") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Assert 'Add account' button is enabled") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsEnabled()
}
}
step("Edit the entered name (clear and retype)") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(editedName)
}
}
step("Assert edited name in 'Account name' field is displayed") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(editedName)
}
}
step("Delete all text and leave 'Account name' field empty") {
onAccountInfoEditorScreen {
accountNameField.performTextClearance()
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Type name with more than 20 symbols") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(longName)
}
}
step("Assert text over 20 symbols was not pasted and placeholder remains empty") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(emptyPlaceholderValue, substring = true)
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Clear text field") {
onAccountInfoEditorScreen { accountNameField.performTextClearance() }
}
step("Paste text longer than 20 characters to 'Account name' field") {
onAccountInfoEditorScreen {
accountNameField.performTouchInput { longClick(durationMillis = 2_000L) }
}
}
step("Click on system 'Paste' button to paste clipboard text") {
clickOnSystemButton(pasteButtonName)
}
step("Assert text over 20 symbols was not pasted and placeholder remains empty") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(emptyPlaceholderValue, substring = true)
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
}
}
@Test
@AllureId("5505")
@DisplayName(
"Accounts: check unsaved changes notification " +
"after attempt to close edited account creation form"
)
fun accountsCreationUnsavedChangesForNameFieldNotificationTest() {
val accountName = "Hikarik Test"
setupHooks().run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open 'Wallet settings' screen") { openWalletSettingsScreen() }
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert edit account details dialog screen appears") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Enter account name manually: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Tap 'Cross' button to attempt closing the screen") {
onAccountInfoEditorScreen {
crossButton.clickWithAssertion()
}
}
step("Verify 'Unsaved changes' screen parts") {
checkUnsavedChangesCreationModal()
}
step("Tap 'Keep Editing' button to stay on screen") {
onDialog { keepEditButton.clickWithAssertion() }
}
step("Assert app still on 'Create account' screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Assert previously entered data is preserved") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Tap 'Cross' button to attempt closing the screen") {
onAccountInfoEditorScreen { crossButton.clickWithAssertion() }
}
step("Assert 'Unsaved changes' alert is displayed again") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Tap 'Discard' button to discard and close") {
onDialog { discardButton.clickWithAssertion() }
}
step("Assert 'Create account' screen is closed and 'Wallet settings' displayed again") {
onWalletSettingsScreen {
screenContainer.assertIsDisplayed()
}
}
step("Verify no new account has appeared in the list") {
onWalletSettingsScreen {
accountItem(accountName).assertDoesNotExist()
}
}
}
}
@Test
@AllureId("5502")
@DisplayName("Accounts: account creation, accounts mode and per-account token derivation")
fun accountCreationAndDerivationTest() {
val createdAccountName = "Account 2"
val accountReadyState = "AccountReadyToCreateDerivation"
val accountIndex = "1"
val btcTokenName = "Bitcoin"
val ethTokenName = "Ethereum"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, accountReadyState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$createdAccountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(createdAccountName)
}
}
step("Assert account creation screen with derivation hint is displayed") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'Add account' and wait for 'Manage Tokens'") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Close 'Manage Tokens' screen") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new account '$createdAccountName' appears (last) in accounts list") {
onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert accounts mode is on main: account '$createdAccountName' section is visible") {
onMainScreen { findAccountSectionByName(createdAccountName).assertIsDisplayed() }
}
step("Assert per-account token derivation paths from the domain account model") {
val account = awaitCryptoPortfolioAccount(derivationIndex = accountIndex.toInt())
val btcPaths = account.derivationPathsForToken(btcTokenName)
assertTrue(
"Expected a $btcTokenName derivation with 3rd node = $accountIndex' (account index). Paths: $btcPaths",
btcPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 3) == "$accountIndex'" },
)
val ethPaths = account.derivationPathsForToken(ethTokenName)
assertTrue(
"Expected an $ethTokenName derivation with 5th node = $accountIndex (account index). Paths: $ethPaths",
ethPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 5) == accountIndex },
)
}
}
}
@Test
@AllureId("8746")
@DisplayName("Accounts: empty account placeholder and 'Add tokens' entry to manage tokens")
fun emptyAccountPlaceholderTest() {
val createdAccountName = "Account 2"
val accountReadyState = "AccountReadyToCreateEmpty"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, accountReadyState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$createdAccountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(createdAccountName)
}
}
step("Click on 'Add account' and wait for 'Manage Tokens'") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Close 'Manage Tokens' without adding any token") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new empty account '$createdAccountName' appears in accounts list") {
onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Expand empty account '$createdAccountName' section") {
onMainScreen {
scrollToAccountSection(createdAccountName)
findAccountSectionByName(createdAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() }
},
)
}
}
step("Assert empty tokens placeholder is displayed") {
onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() }
}
step("Assert 'Add tokens' button is displayed under the placeholder") {
onMainScreen { emptyAccountAddTokensButton.assertIsDisplayed() }
}
step("Click on 'Add tokens' button") {
onMainScreen {
emptyAccountAddTokensButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Assert 'Manage Tokens' screen is opened for the account") {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
}
}
}
}

View file

@ -389,6 +389,9 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
}
step("Click on token: '$tokenTitle'") {
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() }
}
step("Click on 'Confirm' button in 'Dialog'") {
waitForIdle()
onDialog { confirmButton.clickWithAssertion() }

View file

@ -2,16 +2,21 @@ package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSendFromTokenDetails
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
@ -37,20 +42,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Receive' button is displayed") {
onTokenDetailsScreen { receiveButton().assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onTokenDetailsScreen { buyButton().assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onTokenDetailsScreen { sendButton().assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onTokenDetailsScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Swap' button is displayed") {
onTokenDetailsScreen { swapButton().assertIsDisplayed() }
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") {
onTokenDetailsScreen { sellButton().assertIsDisplayed() }
step("Assert 'Transfer' button is displayed") {
onTokenDetailsScreen { transferButton.assertIsDisplayed() }
}
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Buy' button in bottom sheet is displayed") {
onAddFundsBottomSheet { buyButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onAddFundsBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Receive' button in bottom sheet is displayed") {
onAddFundsBottomSheet { receiveButton.assertIsDisplayed() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is displayed") {
onTransferBottomSheet { sendButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onTransferBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Sell' button in bottom sheet is displayed") {
onTransferBottomSheet { sellButton.assertIsDisplayed() }
}
}
}
@ -72,20 +98,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Receive' button is not dimmed") {
onTokenDetailsScreen { receiveButton().assertIsDimmed(false) }
step("Assert 'Add funds' button is enabled") {
onTokenDetailsScreen { addFundsButton.assertIsEnabled() }
}
step("Assert 'Buy' button is not dimmed") {
onTokenDetailsScreen { buyButton().assertIsDimmed(false) }
step("Assert 'Swap' button is disabled") {
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
}
step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
step("Assert 'Transfer' button is enabled") {
onTokenDetailsScreen { transferButton.assertIsEnabled() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed() }
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Sell' button is dimmed") {
onTokenDetailsScreen { sellButton().assertIsDimmed() }
step("Assert 'Buy' button in bottom sheet is enabled") {
onAddFundsBottomSheet { buyButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onAddFundsBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Receive' button in bottom sheet is enabled") {
onAddFundsBottomSheet { receiveButton.assertIsEnabled() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is enabled") {
onTransferBottomSheet { sendButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onTransferBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Sell' button in bottom sheet is disabled") {
onTransferBottomSheet { sellButton.assertIsNotEnabled() }
}
}
}
@ -109,7 +156,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -140,8 +187,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton().performClick() }
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Click on 'Receive' button in bottom sheet") {
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
}
step("Go to QR code bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
@ -153,4 +203,58 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
}
}
}
@AllureId("591")
@DisplayName("Action buttons (token details screen): send available for funded token, unavailable for empty token")
@Test
fun checkSendAvailabilityForFundedAndEmptyTokenTest() {
val emptyTokenTitle = "Polygon"
val fundedTokenTitle = "Ethereum"
val polygonBalanceScenarioName = "polygon_coin_balance"
val polygonBalanceScenarioState = "ZeroBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(polygonBalanceScenarioName)
}
).run {
step("Set WireMock scenario: '$polygonBalanceScenarioName' to state: '$polygonBalanceScenarioState'") {
setWireMockScenarioState(polygonBalanceScenarioName, polygonBalanceScenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$emptyTokenTitle'") {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(emptyTokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Transfer' button is not displayed for the empty token") {
onTokenDetailsScreen { transferButton.assertIsNotDisplayed() }
}
step("Go back to 'Main Screen'") {
device.uiDevice.pressBack()
}
step("Assert 'Main Screen' is displayed") {
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Click on token with name: '$fundedTokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(fundedTokenTitle).clickWithAssertion() }
}
step("Assert 'Transfer' button is displayed for the funded token") {
onTokenDetailsScreen { transferButton.assertIsDisplayed() }
}
step("Open the send flow from token details") {
openSendFromTokenDetails()
}
step("Assert 'Send' screen is displayed") {
onSendScreen { amountInputTextField.assertIsDisplayed() }
}
}
}
}

View file

@ -1,42 +0,0 @@
package com.tangem.tests.balance
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class TotalBalanceLongTapTest : BaseTestCase() {
@Test
@AllureId("3965")
@DisplayName("Total balance: check long tap on block without biometry")
fun whenBiometryIsOffTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long tap on total balance block") {
onMainScreen {
totalBalanceContainer.performTouchInput {
longClick()
}
}
}
step("Assert 'Rename' button is displayed") {
onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() }
}
step("Assert 'Delete' button is not displayed") {
onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.balance
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TOTAL_BALANCE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.*
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -73,7 +74,7 @@ class TotalBalanceUpdateTest : BaseTestCase() {
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }
}
step("Open 'Markets screen'") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
step("Click on $tokenTitle token") {
@ -82,28 +83,27 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
}
step("Click on 'Add to portfolio' button") {
onMarketsScreen { addToPortfolioButton.clickWithAssertion() }
step("Click on 'Add' button in 'Markets' bottom sheet") {
onMarketsScreen { addButton.clickWithAssertion() }
}
step("Click on main network") {
onMarketsScreen { mainNetworkSuffix.performClick() }
}
step("Click on 'Add' button") {
onDialog { addButton.clickWithAssertion() }
}
step("Assert 'Continue' is not displayed") {
onDialog { addButton.assertIsNotDisplayed() }
step("Click on 'Add' button in 'Add token' bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onAddTokenBottomSheet {
addButton.performClick()
}
onAddTokenBottomSheet { laterButton.assertIsDisplayed() }
}
}
step("Click on 'Later' button") {
onDialog { laterButton.clickWithAssertion() }
onAddTokenBottomSheet { laterButton.performClick() }
}
step("Go back to 'Markets: tokens list'") {
step("Press 'Back' button") {
waitForIdle()
onMarketsScreen { topBarBackButton.clickWithAssertion() }
device.uiDevice.pressBack()
}
step("Close 'Markets screen'") {
onSearchBar { searchField.assertIsDisplayed() }
swipeMarketsBlock(SwipeDirection.DOWN)
step("Press 'Back' button") {
waitForIdle()
device.uiDevice.pressBack()
}
step("Assert $updatedBalance is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(updatedBalance) }

View file

@ -0,0 +1,251 @@
package com.tangem.tests.hotWallet
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO
import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO
import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO
import com.tangem.common.constants.TestConstants.SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreenWithExistingHotWallet
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountDetailsScreen
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
import com.tangem.core.ui.R as CoreUiR
@HiltAndroidTest
class AssetsDiscoveryTest : BaseTestCase() {
private companion object {
const val DISCOVERY_TIMEOUT_MILLIS = 120_000L
const val SCENARIO_STATE_STARTED = "Started"
const val SCENARIO_STATE_EMPTY = "Empty"
const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists"
const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect"
const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow"
val EXPECTED_DISCOVERED_TOKENS = listOf(
"Ethereum",
"Polygon",
"Tether",
)
val TOKENS_THAT_MUST_NOT_APPEAR = listOf(
"Solana",
"USDC",
)
val BACKEND_PRE_POPULATED_TOKENS = listOf(
"Bitcoin",
"Ethereum",
"Polygon",
)
}
@AllureId("9280")
@DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path")
@Test
fun newHotWalletImportHappyPathTest() {
val packageName = getTargetContext().packageName
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Wait for 'Wallet successfully imported' banner (discovery completes)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert expected discovered tokens are visible in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Tap 'Check here' (Manage tokens) on the banner") {
onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() }
}
step("Assert 'Manage Tokens' screen is opened") {
onManageTokensScreen { searchField.assertIsDisplayed() }
}
step("Return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert banner is hidden after navigating into Manage Tokens") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Force-close and re-launch the app") {
restartApp(packageName)
}
step("Assert banner is NOT shown again after relaunch") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Assert previously discovered tokens still appear in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Assert zero-balance and spam tokens are NOT shown in the assets list") {
onMainScreen {
TOKENS_THAT_MUST_NOT_APPEAR.forEach { token ->
assertTokenDoesNotExist(token)
}
}
}
}
}
@AllureId("9284")
@DisplayName("Hot wallet: token added manually during Discovery — no duplicate created")
@Test
fun manualTokenAddDuringDiscoveryNoDuplicateTest() {
val tetherTitle = "Tether"
val ethereumNetworkTitle = "ETHEREUM"
val accountName = getResourceString(CoreUiR.string.account_main_account_title)
val expectedTokensCount = 4
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(
MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO,
state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW,
)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Open wallet details from top bar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings'") {
onDetailsScreen { walletNameButton.performClick() }
}
step("Open account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).performClick() }
}
step("Open 'Manage Tokens' from account details") {
onAccountDetailsScreen { manageTokensButton.performClick() }
}
step("Search for '$tetherTitle' in Manage Tokens") {
onManageTokensScreen {
searchField.performClick()
searchField.performTextInput(tetherTitle)
}
device.uiDevice.pressBack()
waitForIdle()
}
step("Expand '$tetherTitle'") {
onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() }
waitForIdle()
}
step("Enable the $ethereumNetworkTitle network") {
onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() }
}
step("Save Manage Tokens changes") {
onManageTokensScreen { saveButton.clickWithAssertion() }
waitForIdle()
}
step("Navigate back to main screen") {
repeat(times = 3) {
device.uiDevice.pressBack()
waitForIdle()
}
}
step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") {
onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() }
}
step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") {
onMainScreen { assertTokensCount(expectedTokensCount) }
}
}
}
@AllureId("9282")
@DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend")
@Test
fun reimportExistingHotWalletTest() {
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY)
},
additionalAfterSection = {
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import an existing hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_12)
}
step("Assert tokens from backend are displayed immediately") {
BACKEND_PRE_POPULATED_TOKENS.forEach { token ->
onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() }
}
}
step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") {
onMainScreen { restoringProgressText.assertIsNotDisplayed() }
}
step("Assert 'Wallet successfully imported' banner is NOT displayed") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
}
}
}

View file

@ -2,6 +2,8 @@ package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -37,7 +39,7 @@ class MainScreenTest : BaseTestCase() {
}
@AllureId("8748")
@DisplayName("Main: check 'Organize tokens' button with single token no accounts")
@DisplayName("Main: check 'Add & Manage' button with single token no accounts")
@Test
fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() {
val scenarioState = "Cardano"
@ -58,14 +60,14 @@ class MainScreenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
}
}
@AllureId("8749")
@DisplayName("Main: check 'Organize tokens' button with single token two accounts")
@DisplayName("Main: check 'Add & Manage' button with single token two accounts")
@Test
fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() {
val scenarioState = "TwoAccountsSingleTokenEach"
@ -99,7 +101,7 @@ class MainScreenTest : BaseTestCase() {
}
@AllureId("8750")
@DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts")
@DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts")
@Test
fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() {
val scenarioState = "TwoAccountsMixed"
@ -117,8 +119,11 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f)
}
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed()}
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
}
}

View file

@ -16,7 +16,7 @@ import org.junit.Test
class WarningsTest : BaseTestCase() {
@AllureId("184")
@DisplayName("Token list: hide token by long tap")
@DisplayName("Warnings: missing address warning")
@Test
fun checkUnavailableNetworksWarningTest() {
val scenarioState = "MissingDerivation"
@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses(isBalanceAvailable = false)
}
step("Assert 'Missing addresses' notification icon is displayed") {
onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() }
}
step("Assert 'Missing addresses' notification title is displayed") {
onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() }
}

View file

@ -1,5 +1,6 @@
package com.tangem.tests.markets
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() {
}
}
@OptIn(ExperimentalTestApi::class)
@Test
@AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@ -53,16 +55,15 @@ class MarketsExchangesTest : BaseTestCase() {
synchronizeAddresses()
}
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle()
}
step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
step("Scroll to 'Listed on exchanges' block") {
onMarketsScreen { scrollToListedOnBlock() }
}
step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }

View file

@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() {
val sendAmount = "1"
val txHistoryScenarioState = "11OutgoingTransactions"
val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq"
val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b"
val longRecipientAddress = recipientAddressBase + "123456789b"
setupHooks(
additionalAfterSection = {
@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() {
checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1)
}
step("Check recent address item №2") {
checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2)
checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2)
}
step("Check recent address item №3") {
checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2)

View file

@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)

View file

@ -4,6 +4,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
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
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
@ -46,8 +47,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$inputAmount' in input text field") {
onSendScreen {
@ -123,8 +127,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$inputAmount' in input text field") {
onSendScreen {
@ -320,4 +327,42 @@ class SendConfirmScreenTest : BaseTestCase() {
}
}
}
@AllureId("557")
@DisplayName("Send (Confirm screen): send a second transaction while the first is still pending")
@Test
fun sendSecondTransactionWhileFirstActiveTest() {
val tokenName = "Ethereum"
val inputAmount = "0.001"
setupHooks().run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$inputAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
// Hold-to-confirm is swallowed while the fee is still settling — wait for it to load first.
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
step("Click on 'Close' button") {
onSendSuccessScreen { closeButton.clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Open the send flow again from token details") {
openSendFromTokenDetails()
}
step("Enter amount '$inputAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
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.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
@ -281,13 +282,13 @@ class SendFeeScreenTest : BaseTestCase() {
fun checkNetworkFeeBottomSheetForBitcoinTest() {
val tokenName = "Bitcoin"
val tokenAmount = "0.00000001"
val feeAmount = "$2.86"
val feeAmount = "$0.48"
val fiatFeeAmount = "$0.24"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
val feeUpTo = getResourceString(R.string.send_max_fee)
val feeUpToValue = "0.0000264 BTC"
val feeUpToValue = "0.0000044 BTC"
val newFeeUpToValue = "0.0000022 BTC"
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
val satoshiValue = "2"
@ -443,4 +444,30 @@ class SendFeeScreenTest : BaseTestCase() {
}
}
}
@AllureId("547")
@DisplayName("Send (Fee screen): network fee recalculates on speed switch and sends")
@Test
fun recalculateFeeOnSpeedSwitchAndSendTest() {
val tokenName = "Ethereum"
val inputAmount = "0.8"
setupHooks().run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$inputAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
val marketFee = getNetworkFeeAmount()
step("Switch the network fee to 'Fast'") {
switchFeeToFastAndApply()
}
assertNetworkFeeChanged(marketFee)
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.tests.send.feeScreen
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
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.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Completing a send paid with a fee in the token itself (no native fee coin), on a hot wallet:
* VeChain's VeThor and Terra Classic's TerraClassicUSD.
*/
@HiltAndroidTest
class SendTokenFeeTest : BaseTestCase() {
private val tokenAmount = "1"
@AllureId("4907")
@DisplayName("Send (Fee in token): send VeThor and complete the transaction")
@Test
fun sendVeThorWithFeeInTokenTest() {
val tokenName = "VeThor"
val scenarioState = "Vechain"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
assertNetworkFeeContains("\$")
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("4908")
@DisplayName("Send (Fee in token): send TerraClassicUSD and complete the transaction")
@Test
fun sendTerraClassicUsdWithFeeInTokenTest() {
val tokenName = "TerraClassicUSD"
val scenarioState = "Terra"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = TERRA_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
assertNetworkFeeContains("\$")
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
}

View file

@ -0,0 +1,317 @@
package com.tangem.tests.send.gasless
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
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.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.enterAmountAndOpenSendConfirm
import com.tangem.scenarios.enterRecipientAndOpenSendConfirm
import com.tangem.scenarios.openSendScreen
import com.tangem.scenarios.selectStablecoinAsFeeToken
import com.tangem.screens.onSendConfirmScreen
import com.tangem.screens.onSendFeeSelectorBottomSheet
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSendSelectNetworkFeeBottomSheet
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 network-fee behaviour on the send summary (fee selector): availability, calculation,
* speed options, switching the fee token, and balance-driven notifications. All run on the default
* (cold) wallet without signing a transaction.
*/
@HiltAndroidTest
class GaslessFeeTest : BaseTestCase() {
private val scenarioState = "PolygonUSDC"
private val tokenName = "USDC"
private val nativeTokenName = "Polygon"
private val tokenAmount = "1"
@AllureId("5061")
@DisplayName("Gasless: Network fee on summary is selectable and the stablecoin is available for the fee")
@Test
fun checkNetworkFeeTokenSelectionAvailableTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeSelectorTitle.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Click on 'Network fee' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Assert 'Network fee' bottom sheet is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Assert '$tokenName' is available for the fee payment") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() }
}
}
}
@AllureId("5062")
@DisplayName("Gasless: network fee for a stablecoin is calculated and shown in the stablecoin")
@Test
fun checkFeeCalculatedInStablecoinTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert the fee is shown under the '$marketSpeed' speed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() }
}
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is calculated in '$tokenName' (not in the coin) on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeBlockCurrency(tokenName).assertIsDisplayed()
feeAmount.assertIsDisplayed()
}
}
}
}
}
@AllureId("5064")
@DisplayName("Gasless: only Market speed is available when paying the fee with a stablecoin")
@Test
fun checkOnlyMarketSpeedAvailableForStablecoinFeeTest() {
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)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
feeSelectorBlock.performClick()
}
}
step("Assert 'Network fee' bottom sheet is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Assert 'Network fee' bottom sheet is displayed after token selection") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Assert '$marketSpeed' speed is displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() }
}
step("Assert '$fastSpeed' speed is not displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() }
}
step("Assert '$slowSpeed' speed is not displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() }
}
step("Click on '$marketSpeed' fee row") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
}
step("Assert 'Choose speed' bottom sheet did not open for stablecoin fee") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
}
}
}
@AllureId("5068")
@DisplayName("Gasless: switching the fee token back to the coin restores the standard fee flow")
@Test
fun checkSwitchFeeTokenBackToCoinTest() {
val nativeSymbol = "POL"
val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Open the fee token selector again via the '$tokenName' fee token") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
step("Switch the fee token back to '$nativeTokenName'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Click on 'Apply' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
}
step("Assert the network fee is now paid in '$nativeSymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() }
}
}
step("Assert 'Network fee coverage' notification is not displayed (standard fee flow)") {
onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsNotDisplayed() }
}
step("Assert 'Send' button is enabled") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
}
}
@AllureId("5063")
@DisplayName("Gasless: insufficient stablecoin balance to cover the fee shows error and blocks send")
@Test
fun checkInsufficientBalanceForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Click on 'Max' button") {
onSendScreen { maxButton.performClick() }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Enter the recipient and open the 'Send confirm' screen") {
enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
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("5097")
@DisplayName("Gasless: no insufficient-coin-for-fee notification is shown when gasless covers the fee")
@Test
fun checkNoInsufficientCoinNotificationWhenGaslessTest() {
val coinBalanceScenario = "polygon_coin_balance"
val zeroBalanceState = "ZeroBalance"
val feeBlockedTitlePart = getResourceString(R.string.warning_send_blocked_funds_for_fee_title, "X")
.substringAfter("X ")
.trim()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(coinBalanceScenario)
}
).run {
step("Set WireMock scenario '$coinBalanceScenario' to '$zeroBalanceState'") {
setWireMockScenarioState(scenarioName = coinBalanceScenario, state = zeroBalanceState)
}
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Assert the fee defaults to '$tokenName' (gasless covers the missing coin)") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(tokenName).assertIsDisplayed() }
}
}
step("Assert the insufficient-coin-for-fee notification is not shown") {
onSendConfirmScreen { warningTitleContaining(feeBlockedTitlePart).assertIsNotDisplayed() }
}
step("Assert 'Send' button is enabled") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
}
}
}

View file

@ -0,0 +1,189 @@
package com.tangem.tests.send.gasless
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
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.clickWithAssertion
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 send lifecycle: signing and broadcasting a stablecoin-fee transaction (hot wallet),
* the max-amount fee reservation, and the completed gasless transaction in the token history.
*/
@HiltAndroidTest
class GaslessSendTest : BaseTestCase() {
private val scenarioState = "PolygonUSDC"
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val nativeTokenName = "Polygon"
private val hotWalletTokensState = "PolygonUSDCHotWallet"
private val tokenAmount = "1"
@AllureId("5069")
@DisplayName("Gasless: max amount reserves the stablecoin fee and stays sendable")
@Test
fun checkMaxAmountSendTest() {
val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title)
val feeCoverageMessagePart = getResourceString(R.string.common_network_fee_warning_content, "", "")
.substringBefore("(")
.trim()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openGaslessSendScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
userTokensState = hotWalletTokensState,
quotesState = scenarioState,
)
}
step("Click on 'Max' button") {
onSendScreen { maxButton.performClick() }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Enter the recipient and open the 'Send confirm' screen") {
enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert 'Network fee coverage' notification title is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsDisplayed() }
}
}
step("Assert 'Network fee coverage' notification text is displayed (amount reduced by fee)") {
onSendConfirmScreen { warningMessageContaining(feeCoverageMessagePart).assertIsDisplayed() }
}
step("Assert 'Send' button is enabled (enough left for the fee)") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("5065")
@DisplayName("Gasless: sign and send a stablecoin transaction with the stablecoin fee")
@Test
fun checkSignAndSendGaslessTransactionTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openGaslessSendScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
userTokensState = hotWalletTokensState,
quotesState = scenarioState,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert gasless fee is paid in '$currencySymbol' and 'Send' is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeBlockCurrency(currencySymbol).assertIsDisplayed()
sendButton.assertIsEnabled()
}
}
}
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("5066")
@DisplayName("Gasless: completed gasless transaction is shown in token transaction history")
@Test
fun checkGaslessTransactionInHistoryTest() {
val sentAmount = "1.00"
val gaslessFeeAmount = "0.10"
val sentTitle = getResourceString(R.string.common_sent)
val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Wait for gasless '$gaslessFeeTitle' transaction in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() }
}
}
step("Assert '$sentTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() }
}
step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true)
transactionCurrency(sentTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(gaslessFeeTitle).assertTextContains(gaslessFeeAmount, substring = true)
transactionCurrency(gaslessFeeTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' status is confirmed") {
onTxHistoryScreen { transactionConfirmedStatus(gaslessFeeTitle).assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,254 @@
package com.tangem.tests.send.sendViaSwap
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO
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.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 send-via-swap: paying the network fee with the stablecoin while converting it through an
* express swap. Covers the fee-token selection on the swap summary, the stablecoin balance validation
* against the gasless fee, and the full signed swap-and-send on a hot wallet.
*/
@HiltAndroidTest
class GaslessSendViaSwapTest : BaseTestCase() {
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val nativeTokenName = "Polygon"
private val swapTokenName = "Bitcoin"
private val mainNetwork = "MAIN"
private val providerName = "Changelly"
private val tokenAmount = "1"
private val hotWalletTokensState = "PolygonUSDCHotWallet"
private val quotesState = "PolygonUSDC"
private val assetsScenarioName = "express_api_assets"
private val assetsExchangeEnabledState = "BitcoinExchangeEnabled"
private val providersState = "HotWalletSvS"
@AllureId("5120")
@DisplayName("Gasless Send via Swap: the network fee is selectable and payable with the stablecoin")
@Test
fun checkFeeTokenSelectionForSwapTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
}
).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 '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeSelectorTitle.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Click on 'Network fee' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Assert '$tokenName' is available for the fee payment") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() }
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is calculated in '$currencySymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
}
}
@AllureId("5121")
@DisplayName("Gasless Send via Swap: insufficient stablecoin balance to cover the fee blocks the swap")
@Test
fun checkBalanceValidationForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
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 '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
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("5122")
@DisplayName("Gasless Send via Swap: sign and send a swap paying the fee with the stablecoin")
@Test
fun checkSendViaSwapFinalScreenAndSendTest() {
val exchangeStatusScenario = "exchange_status_provider"
val changellyStatusState = "Changelly"
val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
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 '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") {
setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the sent '$tokenName' amount is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { primaryAmount.assertIsDisplayed() }
}
}
step("Assert the recipient address is displayed") {
onSendConfirmScreen { recipientAddress(BITCOIN_RECIPIENT_ADDRESS).assertIsDisplayed() }
}
step("Assert the amount to receive after the swap is displayed") {
onSendConfirmScreen { secondaryAmount.assertIsDisplayed() }
}
step("Assert the network fee is paid in '$currencySymbol'") {
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
step("Sign, send and open the 'Transaction sent' screen") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
step("Check 'Send via swap' success screen") {
checkSendViaSwapSuccessScreen()
}
step("Click on 'Close' button") {
onSendSuccessScreen { closeButton.performClick() }
}
step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() }
}
}
}
}
}

View file

@ -0,0 +1,290 @@
package com.tangem.tests.send.warnings
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen
import com.tangem.wallet.R
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
@HiltAndroidTest
class KaspaDustWarningsTest : BaseTestCase() {
private val tokenName = "Kaspa"
private val amountLessThanMinimum = "0.1"
private val amountExactlyMinimum = "0.2"
private val amountMoreThanMinimum = "0.3"
private val amountToLeaveMoreThanMinimumChange = "0.5"
private val amountToLeaveExactlyMinimumChange = "0.79"
private val amountToLeaveLessThanMinimumChange = "0.85"
private val kaspaUTXOScenarioName = "kaspa_utxo"
private val dustState = "dust"
private val dustAmount = "KAS 0.20"
private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title)
private val invalidAmountMessage = getResourceString(
R.string.send_notification_invalid_minimum_amount_text,
dustAmount, dustAmount
)
@AllureId("4685")
@DisplayName("Warnings: invalid amount warning is displayed, when sending less than minimum amount (Kaspa)")
@Test
fun warningIsDisplayedWhenSendingLessThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountLessThanMinimum' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountLessThanMinimum)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage
)
}
}
}
@AllureId("9860")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when sending exactly minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenSendingExactlyMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountExactlyMinimum' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountExactlyMinimum)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4683")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when sending more than minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenSendingMoreThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountMoreThanMinimum' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountMoreThanMinimum)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4684")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when change is more than minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenChangeIsMoreThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountToLeaveMoreThanMinimumChange' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountToLeaveMoreThanMinimumChange)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4682")
@DisplayName("Warnings: invalid amount warning is displayed, when change is less than minimum amount (Kaspa)")
@Test
fun warningIsDisplayedWhenChangeIsLessThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountToLeaveLessThanMinimumChange' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountToLeaveLessThanMinimumChange)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage
)
}
}
}
@AllureId("9861")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when change is exactly minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenChangeIsExactlyMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountToLeaveExactlyMinimumChange' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountToLeaveExactlyMinimumChange)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
}

View file

@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
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.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen
@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() {
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
step("Click 'Next' button until 'Send Confirm' screen opens") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
}
step("Assert 'UTXO limit warning' is displayed") {
checkSendWarning(

View file

@ -3,8 +3,6 @@ package com.tangem.tests.swap
import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -18,101 +16,6 @@ import org.junit.Test
@HiltAndroidTest
class SwapStoriesTest : BaseTestCase() {
@AllureId("5453")
@DisplayName("Check 'Swap' button badge on 'Main' screen")
@Test
fun checkMainScreenSwapButtonBadgeTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
}
}
@AllureId("5454")
@DisplayName("Check 'Swap' button badge on token details screen")
@Test
fun checkTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
}
}
@AllureId("5455")
@DisplayName("Check 'Swap' button badge on token details in 'Market' screen")
@Test
fun checkMarketTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
}
}
}
@AllureId("5469")
@DisplayName("Check unavailable swap stories on 'Main' screen")
@Test
@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
}
@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() {
waitForIdle()
onMainScreen { swapButton.assertIsDisplayed() }
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true)
}
@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") {
restartApp(packageName)
}
step("Assert 'Swap' button has badge") {
waitForIdle()
flakySafely(WAIT_UNTIL_TIMEOUT) {
composeTestRule.mainClock.advanceTimeBy(500)
onMainScreen { swapButton.assertHasBadge() }
}
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
}
@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() {
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks(
additionalBeforeAppLaunchSection = {
@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has not badge") {
step("Assert 'Swap' button is displayed") {
waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") {
restartApp(packageName)
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
step("Assert 'Swap' button is displayed") {
waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true)
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
}
}
}
@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() {
onSwapTokenScreen { closeButton.performClick() }
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
}
}
@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Long click on token with name: '$tokenName' again to reopen actions menu") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenName).performTouchInput {
longClick(
position = center,
durationMillis = 1000L,
)
}
}
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false)
}

View file

@ -50,7 +50,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -147,7 +147,7 @@ class SwapTokenScreenTest : BaseTestCase() {
disableMobileData()
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -201,7 +201,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -304,7 +304,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -510,7 +510,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() }
}
step("Assert 'Swap' button is not dimmed. Swap available") {
onTokenDetailsScreen { swapButton().assertIsDimmed(false) }
onTokenDetailsScreen { swapButton.assertIsEnabled() }
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
@ -519,7 +519,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) }
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
@ -528,7 +528,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) }
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
}
}
}

View file

@ -210,6 +210,17 @@
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="survey"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

@ -1 +1 @@
Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093
Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce

View file

@ -5,9 +5,12 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.wallets.repository.WalletsRepository
@ -49,4 +52,10 @@ interface ApplicationEntryPoint {
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
fun getDeviceKeyManager(): DeviceKeyManager
fun getDeviceRegistrar(): DeviceRegistrar
fun getAuthFeatureToggles(): AuthFeatureToggles
}

View file

@ -200,6 +200,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
}
splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown }
splashScreen.setOnExitAnimationListener { provider -> provider.remove() }
installActivityDependencies()
observeAppThemeModeUpdates()

View file

@ -21,6 +21,9 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.common.LogConfig
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
@ -92,6 +95,15 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val sendTransactionSignerInfoInterceptor
get() = entryPoint.getSendTransactionSignerInfoInterceptor()
private val deviceKeyManager: DeviceKeyManager
get() = entryPoint.getDeviceKeyManager()
private val deviceRegistrar: DeviceRegistrar
get() = entryPoint.getDeviceRegistrar()
private val authFeatureToggles: AuthFeatureToggles
get() = entryPoint.getAuthFeatureToggles()
// endregion
private val appScope = MainScope()
@ -132,6 +144,16 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
}
fun init() {
if (authFeatureToggles.isBackendAuthenticationEnabled) {
appScope.launch {
// Order matters: registration reads the device public key, so it must wait for
// generation to complete. Running them concurrently on first launch would race —
// register() would see `DeviceKeyUnavailable` and defer to the next app launch.
deviceKeyManager.generateIfMissing()
deviceRegistrar.register()
.onLeft { error -> TangemLogger.w("Device registration deferred: $error") }
}
}
walletsRepository = entryPoint.getWalletsRepository()
apiConfigsManager.initialize()

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.SignIn
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
class HotWalletContextInterceptor(
val parent: ParamsInterceptor? = null,
@ -18,6 +19,7 @@ class HotWalletContextInterceptor(
is SignIn.ButtonAddWallet,
is SignIn.ButtonUnlockAllWithBiometric,
is IntroductionProcess.ButtonScanCard,
is TokenScreenAnalyticsEvent.ButtonQuickTopUp,
-> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true

View file

@ -6,6 +6,8 @@ import android.net.Uri
import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.common.uri.ExternalUrlValidator
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.logging.TangemLogger
@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger
internal class DefaultDeeplinkLauncher(
private val context: Context,
private val urlOpener: UrlOpener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : DeeplinkLauncher {
override fun launch(link: String) {
@ -58,11 +61,33 @@ internal class DefaultDeeplinkLauncher(
}
private fun launchDeepLink(uri: Uri) {
context.startActivity(createDeepLinkIntent(uri))
val intent = createDeepLinkIntent(uri)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
TangemLogger.i(
"""
No match found for deep link
|- Received URI: $uri
""".trimIndent(),
)
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = UnresolvedDeeplinkException(uri),
params = mapOf(
"uri_scheme" to uri.scheme.orEmpty(),
"uri_host" to uri.host.orEmpty(),
),
),
)
}
}
private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
setPackage(context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
internal class UnresolvedDeeplinkException(uri: Uri) :
RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}")

View file

@ -4,15 +4,20 @@ import android.app.Application
import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.tangem.Log
import com.tangem.TangemSdkLogger
import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.common.LogConfig
import com.tangem.operations.attestation.api.TangemApiServiceSettings
import com.tangem.utils.JsonStringValuesExtractor
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import kotlinx.serialization.json.Json
/**
* Owns all app-startup wiring of the logging subsystem in a single place:
@ -23,12 +28,15 @@ import com.tangem.wallet.BuildConfig
* @property appLogsStore app logs store used by file-based writer and the network logs save
* interceptor
* @property tangemSdkLogger Card SDK logger registered with [Log.addLogger]
* @property environmentConfig source of [BlockchainSdkConfig] used to build the blockchain
* URL masker
*
[REDACTED_AUTHOR]
*/
class TangemLoggingInitializer(
private val appLogsStore: AppLogsStore,
private val tangemSdkLogger: TangemSdkLogger,
private val environmentConfig: EnvironmentConfig,
) {
fun initAppLogging() {
@ -64,6 +72,13 @@ class TangemLoggingInitializer(
}
add(createNetworkLoggingInterceptor())
add(ChuckerInterceptor(application))
add(
NetworkLogsSaveInterceptor(
appLogsStore = appLogsStore,
sensitiveUrlMasker = createBlockchainSensitiveUrlMasker(),
shouldCheckResponseBodySize = true,
),
)
}
TangemApiServiceSettings.addInterceptors(
@ -77,4 +92,16 @@ class TangemLoggingInitializer(
}.toTypedArray(),
)
}
private fun createBlockchainSensitiveUrlMasker(): SensitiveUrlMasker {
val json = Json.encodeToJsonElement(
BlockchainSdkConfig.serializer(),
environmentConfig.blockchainSdkConfig,
)
// Drop URL-shaped drawable (e.g. public endpoint URLs from BlockchainSdkConfig like
// kaspaSecondaryApiUrl); they are not secrets and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
return SensitiveUrlMasker(values)
}
}

View file

@ -1,29 +1,89 @@
package com.tangem.tap.data
import androidx.datastore.core.DataStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.data.converter.PendingOfframpEntryConverter
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Default implementation of [OfframpRepository]
* Default implementation of [OfframpRepository].
*
* @property sellService sell service for getting offramp URL
* @property pendingOfframpStore dedicated kotlinx-serialized store of app-initiated sells
* @property dispatchers coroutine dispatchers provider for IO operations
*/
internal class DefaultOfframpRepository(
private val sellService: SellService,
private val pendingOfframpStore: DataStore<List<PendingOfframpEntry>>,
private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter()
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String,
walletAddress: String,
requestId: String,
): String? {
return sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
requestId = requestId,
)
}
override suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String =
withContext(dispatchers.io) {
val requestId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
pendingOfframpStore.updateData { stored ->
stored.filterNotExpired(now) + PendingOfframpEntry(
requestId = requestId,
userWalletId = userWalletId.stringValue,
currencyId = currencyId,
createdAt = now,
)
}
requestId
}
override suspend fun consumePendingOfframp(
requestId: String,
userWalletId: UserWalletId,
currencyId: String,
): PendingOfframp? = withContext(dispatchers.io) {
val now = System.currentTimeMillis()
var matched: PendingOfframpEntry? = null
pendingOfframpStore.updateData { stored ->
matched = stored.firstOrNull { entry ->
entry.requestId == requestId &&
entry.userWalletId == userWalletId.stringValue &&
entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS
}
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it.
stored.filter { it != matched }.filterNotExpired(now)
}
matched?.let(pendingOfframpConverter::convert)
}
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
filter { now - it.createdAt < EXPIRY_MS }
private companion object {
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
}

View file

@ -267,6 +267,19 @@ internal class DefaultTangemPayStorage @Inject constructor(
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false)
// Clear the withdraw order hints together with the rest of the cache.
deleteActiveWithdrawOrder(userWalletId)
clearWithdrawOrders(userWalletId)
}
private suspend fun clearWithdrawOrders(userWalletId: UserWalletId) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
.orEmpty()
val updatedMap = currentMap - walletKey
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
}
}
private fun createAuthTokensKey(address: String): String = "${AUTH_TOKENS_DEFAULT_KEY}_$address"

View file

@ -0,0 +1,19 @@
package com.tangem.tap.data.converter
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.utils.converter.Converter
/**
* Converts a persisted [PendingOfframpEntry] into the domain [PendingOfframp].
*/
internal class PendingOfframpEntryConverter : Converter<PendingOfframpEntry, PendingOfframp> {
override fun convert(value: PendingOfframpEntry): PendingOfframp = PendingOfframp(
requestId = value.requestId,
userWalletId = UserWalletId(stringValue = value.userWalletId),
currencyId = value.currencyId,
createdAt = value.createdAt,
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.tap.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Persisted entry of an app-initiated sell (off-ramp) flow, stored in a dedicated kotlinx-serialized DataStore.
*
* [userWalletId] holds the [com.tangem.domain.models.wallet.UserWalletId.stringValue].
*
* @see com.tangem.domain.offramp.model.PendingOfframp
*/
@Serializable
internal data class PendingOfframpEntry(
@SerialName("requestId")
val requestId: String,
@SerialName("userWalletId")
val userWalletId: String,
@SerialName("currencyId")
val currencyId: String,
@SerialName("createdAt")
val createdAt: Long,
)

View file

@ -5,12 +5,10 @@ import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
@ -34,8 +32,6 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository,
): TangemSdkManager {
@ -49,8 +45,6 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
blockchainToDeriveFinder = blockchainToDeriveFinder,
analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository,
)

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher
@ -55,7 +56,10 @@ internal interface UtilsModule {
@Provides
@Singleton
fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher =
DefaultDeeplinkLauncher(context, urlOpener)
fun provideDeeplinkLauncher(
@ApplicationContext context: Context,
urlOpener: UrlOpener,
analyticsExceptionHandler: AnalyticsExceptionHandler,
): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.data
import com.tangem.blockchain.common.logging.BlockchainSDKLogger
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.tap.common.log.TangemBlockchainSDKLogger
import com.tangem.tap.common.log.TangemCardSDKLogger
@ -17,10 +18,14 @@ internal object TangemLoggingModule {
@Provides
@Singleton
fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer {
fun provideLoggingInitializer(
appLogsStore: AppLogsStore,
environmentConfig: EnvironmentConfig,
): TangemLoggingInitializer {
return TangemLoggingInitializer(
appLogsStore = appLogsStore,
tangemSdkLogger = TangemCardSDKLogger(appLogsStore),
environmentConfig = environmentConfig,
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.tap.di.domain
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AddressBookDomainModule {
@Provides
@Singleton
fun provideValidateContactAddressUseCase(
validateWalletAddressUseCase: ValidateWalletAddressUseCase,
getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
): ValidateContactAddressUseCase {
return ValidateContactAddressUseCase(
validateWalletAddressUseCase = validateWalletAddressUseCase,
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
)
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.tap.di.domain
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.data.DefaultOfframpRepository
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.builtins.ListSerializer
@Module
@InstallIn(SingletonComponent::class)
internal object OfframpDomainModule {
@Provides
@Singleton
fun providePendingOfframpStore(
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): DataStore<List<PendingOfframpEntry>> = DataStoreFactory.create(
serializer = KotlinxDataStoreSerializer(
defaultValue = emptyList(),
serializer = ListSerializer(PendingOfframpEntry.serializer()),
),
produceFile = { context.dataStoreFile(fileName = "pending_offramps") },
scope = appScope,
)
@Provides
@Singleton
fun provideOfframpRepository(
sellService: SellService,
pendingOfframpStore: DataStore<List<PendingOfframpEntry>>,
dispatchers: CoroutineDispatcherProvider,
): OfframpRepository {
return DefaultOfframpRepository(sellService, pendingOfframpStore, dispatchers)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

@ -1,12 +1,8 @@
package com.tangem.tap.di.domain
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.tap.data.DefaultOfframpRepository
import com.tangem.tap.network.exchangeServices.SellService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -270,16 +266,4 @@ internal object OnrampDomainModule {
settingsRepository = settingsRepository,
)
}
@Provides
@Singleton
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
return DefaultOfframpRepository(sellService)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase
import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import dagger.Module
@ -37,4 +38,12 @@ internal object PushNotificationPreferencesDomainModule {
): UpdateWalletPushNotificationPreferenceUseCase {
return UpdateWalletPushNotificationPreferenceUseCase(repository = repository)
}
@Provides
@Singleton
fun providesSetAllWalletPushNotificationPreferencesUseCase(
repository: WalletPushNotificationPreferencesRepository,
): SetAllWalletPushNotificationPreferencesUseCase {
return SetAllWalletPushNotificationPreferencesUseCase(repository = repository)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.common.wallets.UserWalletsListRepository
@ -10,19 +11,20 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.stories.StoriesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.stories.StoriesRepository
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -162,6 +164,8 @@ internal object TokensDomainModule {
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
stakingIdFactory: StakingIdFactory,
dispatchers: CoroutineDispatcherProvider,
): WalletBalanceFetcher {
@ -175,6 +179,8 @@ internal object TokensDomainModule {
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiStakingBalanceFetcher = multiStakingBalanceFetcher,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
stakingIdFactory = stakingIdFactory,
dispatchers = dispatchers,
)

View file

@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule {
)
}
@Provides
@Singleton
fun provideWrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): WrapYieldSwapCallDataWithUpgradeUseCase {
return WrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository = yieldSupplyTransactionRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetProtocolBalanceUseCase(

View file

@ -27,7 +27,6 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
@ -58,6 +57,7 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@ -73,8 +73,6 @@ internal class DefaultTangemSdkManager(
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository,
) : TangemSdkManager {
@ -145,12 +143,10 @@ internal class DefaultTangemSdkManager(
runTaskAsyncReturnOnMain(
runnable = ScanProductTask(
card = null,
blockchainToDeriveFinder = blockchainToDeriveFinder,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
cardRepository = cardRepository,
),
@ -242,6 +238,7 @@ internal class DefaultTangemSdkManager(
Analytics.send(event = analyticsEvent.withParams(params.toMap()))
}
.doOnFailure { tangemError ->
TangemLogger.e("scanProduct failed: code=${tangemError.code}, message=${tangemError.customMessage}")
(tangemError as? TangemSdkError)?.let { error ->
Analytics.sendErrorEvent(TangemSdkErrorEvent(error))
}
@ -470,7 +467,6 @@ internal class DefaultTangemSdkManager(
runnable = FinalizeTwinTask(
twinPublicKey = secondCardPublicKey,
issuerKeys = issuerKeyPair,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
cardRepository = cardRepository,
),
cardId = cardId,

View file

@ -36,6 +36,9 @@ object MockProvider {
MockOption("Backup Wallet") { BackupWalletMockContent },
MockOption("Dev Wallet") { DevWalletMockContent },
MockOption("Firmware 4.12") { Firmware412MockContent },
MockOption("V3 Multicurrency") { V3MockContent },
MockOption("Single Currency") { SingleCurrencyMockContent },
MockOption("Start2Coin") { S2CMockContent },
MockOption("Cobrand") { showCobrandConfigDialog(it) },
)
@ -99,6 +102,7 @@ object MockProvider {
ProductType.Note -> NoteMockContent
ProductType.Ring -> RingMockContent
ProductType.Twins -> TwinsMockContent
ProductType.Start2Coin -> S2CMockContent
else -> TODO()
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.common.SuccessResponse
import com.tangem.common.card.*
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.attestation.Attestation
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.sdk.api.CreateProductWalletTaskResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
import java.util.Date
// Start2Coin (S2C): issuer "Start2Coin" trips isStart2Coin → single currency, WalletConnect hidden.
object S2CMockContent : MockContent {
override val cardDto = CardDTO(
cardId = "1198724260000000",
batchId = "CD04",
cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119),
firmwareVersion = CardDTO.FirmwareVersion(
major = 4,
minor = 52,
patch = 0,
type = FirmwareVersion.FirmwareType.Release,
),
manufacturer = CardDTO.Manufacturer(
name = "TANGEM",
manufactureDate = Date(1671494400000),
signature = byteArrayOf(),
),
issuer = CardDTO.Issuer(
name = "Start2Coin",
publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2),
),
settings = CardDTO.Settings(
securityDelay = 15000,
maxWalletsCount = 1,
isSettingAccessCodeAllowed = false,
isSettingPasscodeAllowed = false,
isResettingUserCodesAllowed = true,
isLinkedTerminalEnabled = true,
isBackupAllowed = false,
supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None),
isFilesAllowed = false,
isHDWalletAllowed = false,
isKeysImportAllowed = false,
),
userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true),
linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None,
isAccessCodeSet = false,
isPasscodeSet = false,
supportedCurves = listOf(EllipticCurve.Secp256k1),
wallets = listOf(
CardDTO.Wallet(
publicKey = byteArrayOf(2, 106, 7, -77, -109, 39, 3, 80, 99, 31, 50, -40, -113, -81, -76, -21, 123, -60, 0, -121, -56, 126, 2, 123, 111, 80, 47, -37, 40, 119, -22, 33, 32),
chainCode = byteArrayOf(),
curve = EllipticCurve.Secp256k1,
settings = CardWallet.Settings(isPermanent = true),
totalSignedHashes = 1,
remainingSignatures = 999999,
index = 0,
hasBackup = false,
derivedKeys = emptyMap(),
extendedPublicKey = null,
isImported = false,
),
),
attestation = Attestation(
cardKeyAttestation = Attestation.Status.Verified,
walletKeysAttestation = Attestation.Status.Skipped,
firmwareAttestation = Attestation.Status.Skipped,
cardUniquenessAttestation = Attestation.Status.Skipped,
),
backupStatus = CardDTO.BackupStatus.NoBackup,
)
override val scanResponse = ScanResponse(
card = cardDto,
productType = ProductType.Start2Coin,
walletData = WalletData(blockchain = "BTC", token = null),
secondTwinPublicKey = null,
derivedKeys = emptyMap(),
primaryCard = null,
)
override val derivationTaskResponse = DerivationTaskResponse(entries = emptyMap())
override val extendedPublicKey
get() = error("Available only for wallet+?")
override val successResponse = SuccessResponse(cardId = "1198724260000000")
override val createProductWalletTaskResponse = CreateProductWalletTaskResponse(
card = cardDto,
derivedKeys = emptyMap(),
primaryCard = null,
)
override val importWalletResponse: CreateProductWalletTaskResponse
get() = error("Available only for Wallet 2")
override val createFirstTwinResponse: CreateWalletResponse
get() = error("Available only for Twin")
override val createSecondTwinResponse: CreateWalletResponse
get() = error("Available only for Twin")
override val finalizeTwinResponse: ScanResponse
get() = error("Available only for Twin")
}

View file

@ -0,0 +1,112 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.common.SuccessResponse
import com.tangem.common.card.*
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.attestation.Attestation
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.sdk.api.CreateProductWalletTaskResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
import java.util.Date
// Single-currency card (XLM/ed25519, pre-4.0 firmware) → isMultiwalletAllowed false → WalletConnect hidden.
object SingleCurrencyMockContent : MockContent {
override val cardDto = CardDTO(
cardId = "0052000000000000",
batchId = "0052",
cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119),
firmwareVersion = CardDTO.FirmwareVersion(
major = 3,
minor = 5,
patch = 0,
type = FirmwareVersion.FirmwareType.Release,
),
manufacturer = CardDTO.Manufacturer(
name = "TANGEM",
manufactureDate = Date(1649635200000),
signature = byteArrayOf(),
),
issuer = CardDTO.Issuer(
name = "TANGEM AG",
publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2),
),
settings = CardDTO.Settings(
securityDelay = 15000,
maxWalletsCount = 1,
isSettingAccessCodeAllowed = false,
isSettingPasscodeAllowed = false,
isResettingUserCodesAllowed = true,
isLinkedTerminalEnabled = true,
isBackupAllowed = false,
supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None),
isFilesAllowed = false,
isHDWalletAllowed = false,
isKeysImportAllowed = false,
),
userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = false),
linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None,
isAccessCodeSet = false,
isPasscodeSet = false,
supportedCurves = listOf(EllipticCurve.Ed25519),
wallets = listOf(
CardDTO.Wallet(
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(),
curve = EllipticCurve.Ed25519,
settings = CardWallet.Settings(isPermanent = false),
totalSignedHashes = 1,
remainingSignatures = null,
index = 0,
hasBackup = false,
derivedKeys = emptyMap(),
extendedPublicKey = null,
isImported = false,
),
),
attestation = Attestation(
cardKeyAttestation = Attestation.Status.Verified,
walletKeysAttestation = Attestation.Status.Skipped,
firmwareAttestation = Attestation.Status.Skipped,
cardUniquenessAttestation = Attestation.Status.Skipped,
),
backupStatus = CardDTO.BackupStatus.NoBackup,
)
override val scanResponse = ScanResponse(
card = cardDto,
productType = ProductType.Wallet,
walletData = WalletData(blockchain = "XLM", token = null),
secondTwinPublicKey = null,
derivedKeys = emptyMap(),
primaryCard = null,
)
override val derivationTaskResponse = DerivationTaskResponse(entries = emptyMap())
override val extendedPublicKey
get() = error("Available only for wallet+?")
override val successResponse = SuccessResponse(cardId = "0052000000000000")
override val createProductWalletTaskResponse = CreateProductWalletTaskResponse(
card = cardDto,
derivedKeys = emptyMap(),
primaryCard = null,
)
override val importWalletResponse: CreateProductWalletTaskResponse
get() = error("Available only for Wallet 2")
override val createFirstTwinResponse: CreateWalletResponse
get() = error("Available only for Twin")
override val createSecondTwinResponse: CreateWalletResponse
get() = error("Available only for Twin")
override val finalizeTwinResponse: ScanResponse
get() = error("Available only for Twin")
}

View file

@ -0,0 +1,112 @@
package com.tangem.tap.domain.sdk.mocks.content
import com.tangem.common.SuccessResponse
import com.tangem.common.card.*
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.attestation.Attestation
import com.tangem.operations.derivation.DerivationTaskResponse
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.sdk.api.CreateProductWalletTaskResponse
import com.tangem.tap.domain.sdk.mocks.MockContent
import java.util.Date
// v3 multicurrency card: single secp256k1 wallet on pre-4.0 firmware → isMultiwalletAllowed via the secp branch.
object V3MockContent : MockContent {
override val cardDto = CardDTO(
cardId = "0045000000000000",
batchId = "0045",
cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119),
firmwareVersion = CardDTO.FirmwareVersion(
major = 3,
minor = 5,
patch = 0,
type = FirmwareVersion.FirmwareType.Release,
),
manufacturer = CardDTO.Manufacturer(
name = "TANGEM",
manufactureDate = Date(1649635200000),
signature = byteArrayOf(),
),
issuer = CardDTO.Issuer(
name = "TANGEM AG",
publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2),
),
settings = CardDTO.Settings(
securityDelay = 15000,
maxWalletsCount = 1,
isSettingAccessCodeAllowed = false,
isSettingPasscodeAllowed = false,
isResettingUserCodesAllowed = true,
isLinkedTerminalEnabled = true,
isBackupAllowed = false,
supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None),
isFilesAllowed = false,
isHDWalletAllowed = false,
isKeysImportAllowed = false,
),
userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = false),
linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None,
isAccessCodeSet = false,
isPasscodeSet = false,
supportedCurves = listOf(EllipticCurve.Secp256k1),
wallets = listOf(
CardDTO.Wallet(
publicKey = byteArrayOf(2, -27, -117, 23, 68, -3, 21, -109, 18, -67, -107, -42, -44, -16, -127, -53, 46, -109, -46, -51, 89, 119, 79, 111, 78, 62, -125, 72, 109, 8, 45, 59, 117),
chainCode = byteArrayOf(),
curve = EllipticCurve.Secp256k1,
settings = CardWallet.Settings(isPermanent = false),
totalSignedHashes = 1,
remainingSignatures = null,
index = 0,
hasBackup = false,
derivedKeys = emptyMap(),
extendedPublicKey = null,
isImported = false,
),
),
attestation = Attestation(
cardKeyAttestation = Attestation.Status.Verified,
walletKeysAttestation = Attestation.Status.Skipped,
firmwareAttestation = Attestation.Status.Skipped,
cardUniquenessAttestation = Attestation.Status.Skipped,
),
backupStatus = CardDTO.BackupStatus.NoBackup,
)
override val scanResponse = ScanResponse(
card = cardDto,
productType = ProductType.Wallet,
walletData = WalletData(blockchain = "BTC", token = null),
secondTwinPublicKey = null,
derivedKeys = emptyMap(),
primaryCard = null,
)
override val derivationTaskResponse = DerivationTaskResponse(entries = emptyMap())
override val extendedPublicKey
get() = error("Available only for wallet+?")
override val successResponse = SuccessResponse(cardId = "0045000000000000")
override val createProductWalletTaskResponse = CreateProductWalletTaskResponse(
card = cardDto,
derivedKeys = emptyMap(),
primaryCard = null,
)
override val importWalletResponse: CreateProductWalletTaskResponse
get() = error("Available only for Wallet 2")
override val createFirstTwinResponse: CreateWalletResponse
get() = error("Available only for Twin")
override val createSecondTwinResponse: CreateWalletResponse
get() = error("Available only for Twin")
override val finalizeTwinResponse: ScanResponse
get() = error("Available only for Twin")
}

View file

@ -1,74 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import javax.inject.Inject
/**
* Finder of blockchains to derive.
* Returns only saved, default or demo blockchains without any additional logic
* (no cardano/ethereum additions or unnecessary blockchain removals).
*/
class BlockchainToDeriveFinder @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) {
suspend fun find(card: CardDTO): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = card.derivationStyleProvider.getDerivationStyle()
val blockchains = getBlockchains(userWalletId).ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): Set<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
.toSet()
}
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
return mapNotNullTo(hashSetOf()) { blockchain ->
val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null
BlockchainToDerive(blockchain, derivationPath)
}
}
}

View file

@ -12,8 +12,6 @@ import com.tangem.common.extensions.*
import com.tangem.common.tlv.Tlv
import com.tangem.common.tlv.TlvDecoder
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.card.common.TapWorkarounds.isExcluded
import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
@ -32,25 +30,21 @@ import com.tangem.operations.PreflightReadMode
import com.tangem.operations.ScanTask
import com.tangem.operations.backup.PrimaryCard
import com.tangem.operations.backup.StartPrimaryCardLinkingTask
import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask
import com.tangem.operations.files.ReadFilesTask
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
import com.tangem.tap.domain.TapSdkError
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.mainScope
import com.tangem.tap.scope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class ScanProductTask(
private val card: Card?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
private val shouldCheckIsAlreadyActivated: Boolean,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository,
override val allowsRequestAccessCodeFromRepository: Boolean = false,
) : CardSessionRunnable<ScanResponse> {
@ -80,8 +74,6 @@ internal class ScanProductTask(
session = session,
cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor(
blockchainToDeriveFinder = blockchainToDeriveFinder,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
cardRepository = cardRepository,
),
callback = callback,
@ -92,8 +84,6 @@ internal class ScanProductTask(
val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(
blockchainToDeriveFinder = blockchainToDeriveFinder,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
cardRepository = cardRepository,
)
}
@ -102,8 +92,8 @@ internal class ScanProductTask(
is CompletionResult.Success -> ScanTask().run(session) { scanTaskResult ->
when (scanTaskResult) {
is CompletionResult.Success -> {
// it needed because processorResult.data.card doesn't contains attestation result
// and CardWallet.derivedKeys
// It's needed because processorResult.data.card doesn't contain the attestation
// result or the existing CardWallet.derivedKeys read from the card.
val processorScanResponseWithNewCard = processorResult.data.copy(
card = CardDTO(scanTaskResult.data),
)
@ -176,8 +166,6 @@ internal class ScanProductTask(
}
private class ScanWalletProcessor(
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository,
) : ProductCommandProcessor<ScanResponse> {
@ -281,48 +269,34 @@ private class ScanWalletProcessor(
when (linkingResult) {
is CompletionResult.Success -> {
primaryCard = linkingResult.data
deriveKeysIfNeeded(card, session, callback)
completeScan(card, session, callback)
}
is CompletionResult.Failure -> {
deriveKeysIfNeeded(card, session, callback)
completeScan(card, session, callback)
}
}
}
} else {
deriveKeysIfNeeded(card, session, callback)
completeScan(card, session, callback)
}
}
}
private fun deriveKeysIfNeeded(
// Keys are no longer derived during scan: default derivations are created up front in
// CreateProductWalletTask, and derivations for additional tokens are handled by
// DefaultColdMapDerivationsRepository when the user explicitly adds a token.
private fun completeScan(
card: CardDTO,
session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val productType = getWalletProductType(card)
scope.launch {
val scanResponse = ScanResponse(
card = card,
productType = productType,
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
val derivations = collectDerivations(card, scanResponse)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(CompletionResult.Success(scanResponse))
return@launch
}
DeriveMultipleWalletPublicKeysTask(derivations).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val response = scanResponse.copy(derivedKeys = result.data.entries)
callback(CompletionResult.Success(response))
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
val scanResponse = ScanResponse(
card = card,
productType = getWalletProductType(card),
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
callback(CompletionResult.Success(scanResponse))
}
private fun getWalletProductType(card: CardDTO): ProductType {
@ -334,17 +308,6 @@ private class ScanWalletProcessor(
else -> ProductType.Wallet
}
}
private suspend fun collectDerivations(
card: CardDTO,
scanResponse: ScanResponse,
): Map<ByteArrayKey, List<DerivationPath>> {
val blockchains = blockchainToDeriveFinder
?.find(card)
?: return emptyMap()
return MissedDerivationsFinder(scanResponse, isDynamicAddressesEnabled).findByBlockchainsToDerive(blockchains)
}
}
@Suppress("MagicNumber")

View file

@ -13,7 +13,6 @@ import com.tangem.tap.domain.tasks.product.ScanProductTask
class FinalizeTwinTask(
private val twinPublicKey: ByteArray,
private val issuerKeys: KeyPair,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository,
) : CardSessionRunnable<ScanResponse> {
@ -31,11 +30,9 @@ class FinalizeTwinTask(
is CompletionResult.Success ->
ScanProductTask(
card = readResult.data,
blockchainToDeriveFinder = null,
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null,
cardRepository = cardRepository,
).run(session, callback)

View file

@ -242,6 +242,10 @@ internal class DefaultUserWalletsListRepository(
setSelectedUserWallet(newSelected)
}
userWallets.value = updatedWallets
if (updatedWallets?.isEmpty() == true) {
trackingContextProxy.eraseContext()
}
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
@ -325,11 +329,7 @@ internal class DefaultUserWalletsListRepository(
sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo ->
updateWallets { wallets ->
// It is necessary to update derivations because when scanning we obtain the missing keys
wallets?.updateWith(
walletIdToSensitiveInformation = sensitiveInfo,
walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys),
)
wallets?.updateWith(walletIdToSensitiveInformation = sensitiveInfo)
}
trackSignInEvent(userWallet, AnalyticsParam.SignInType.Card)
}

View file

@ -1,10 +1,8 @@
package com.tangem.tap.domain.userWalletList.utils
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
@ -74,10 +72,7 @@ internal fun List<UserWalletPublicInformation>.toUserWallets(): List<UserWallet>
return this.map { it.toUserWallet() }
}
internal fun UserWallet.updateWith(
sensitiveInformation: UserWalletSensitiveInformation,
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap>?,
): UserWallet {
internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInformation): UserWallet {
return when (this) {
is UserWallet.Cold -> {
copy(
@ -85,7 +80,6 @@ internal fun UserWallet.updateWith(
card = scanResponse.card.copy(
wallets = requireNotNull(sensitiveInformation.wallets),
),
derivedKeys = derivedKeys ?: scanResponse.derivedKeys,
// visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus,
),
)
@ -98,17 +92,14 @@ internal fun UserWallet.updateWith(
internal fun List<UserWallet>.updateWith(
walletIdToSensitiveInformation: Map<UserWalletId, UserWalletSensitiveInformation>,
walletIdToDerivedKeys: Map<UserWalletId, Map<KeyWalletPublicKey, ExtendedPublicKeysMap>>? = null,
): List<UserWallet> {
return if (walletIdToSensitiveInformation.isEmpty()) {
this
} else {
this.map { wallet ->
val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId]
val derivedKeys = walletIdToDerivedKeys?.get(wallet.walletId)
if (sensitiveInformation != null) {
wallet.updateWith(sensitiveInformation, derivedKeys)
wallet.updateWith(sensitiveInformation)
} else {
wallet
}

Some files were not shown because too many files have changed in this diff Show more