Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 14:08:01 +03:00
commit 06a915a0f4
2186 changed files with 101417 additions and 36593 deletions

View file

@ -125,6 +125,7 @@ dependencies {
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.appUpdate)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
@ -166,6 +167,7 @@ dependencies {
implementation(projects.domain.walletManager)
implementation(projects.domain.walletManager.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.polymarket)
implementation(projects.domain.promo)
implementation(projects.domain.blockaid)
implementation(projects.domain.hotWallet)
@ -195,6 +197,7 @@ dependencies {
implementation(projects.libs.tangemSdkApi)
implementation(projects.data.account)
implementation(projects.data.addressBook)
implementation(projects.data.appCurrency)
implementation(projects.data.appTheme)
implementation(projects.data.balanceHiding)
@ -202,6 +205,7 @@ dependencies {
implementation(projects.data.card)
implementation(projects.data.common)
implementation(projects.data.settings)
implementation(projects.data.appUpdate)
implementation(projects.data.tokens)
implementation(projects.data.assetsdiscovery)
implementation(projects.data.txhistory)
@ -229,6 +233,7 @@ dependencies {
implementation(projects.data.swap)
implementation(projects.data.walletManager)
implementation(projects.data.yieldSupply)
implementation(projects.data.polymarket)
implementation(projects.data.promo)
implementation(projects.data.hotWallet)
implementation(projects.data.news)
@ -271,6 +276,8 @@ dependencies {
implementation(projects.features.details.impl)
implementation(projects.features.disclaimer.api)
implementation(projects.features.disclaimer.impl)
implementation(projects.features.forceUpdate.api)
implementation(projects.features.forceUpdate.impl)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.pushNotifications.impl)
implementation(projects.features.pushNotificationSettings.api)
@ -337,8 +344,12 @@ dependencies {
implementation(projects.features.tokenRecieve.impl)
implementation(projects.features.yieldSupply.api)
implementation(projects.features.yieldSupply.impl)
implementation(projects.features.polymarket.api)
implementation(projects.features.polymarket.impl)
implementation(projects.features.approval.api)
implementation(projects.features.approval.impl)
implementation(projects.features.forYou.api)
implementation(projects.features.forYou.impl)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
@ -398,6 +409,7 @@ dependencies {
kapt(deps.hilt.compilerx)
/** Other libraries */
implementation(deps.arrow.fx)
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.googlePlay.review)

View file

@ -18,6 +18,7 @@ import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.tangem.common.allure.FailedStepScreenshotInterceptor
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_NAME
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_VALUE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore
@ -179,50 +180,29 @@ abstract class BaseTestCase : TestCase(
fun waitForIdle() = composeTestRule.waitForIdle()
/**
* Waits until [block] stops throwing (or [timeoutMillis] elapses). Use in scenario (BaseTestCase extension)
* code where flakySafely is unavailable; in test bodies prefer flakySafely.
*/
fun awaitSuccess(timeoutMillis: Long = WAIT_UNTIL_TIMEOUT, block: () -> Unit) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { runCatching(block).isSuccess }
}
private fun applicationInjectionRule(): ApplicationInjectionExecutionRule {
return ApplicationInjectionExecutionRule(
toggleStates = mapOf(
"SWAP_REDESIGN_ENABLED" to false,
"ACCOUNTS_FEATURE_ENABLED" to true,
"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,
// Version-gated toggles released in versions <= 6.0 — forced on so tests run against the actual
// build even when the app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < x.xx would
// disable them). On the releases/6.0 branch every toggle with version <= 6.0 ships enabled.
// 5.37
"HEDERA_ERC20_ENABLED" to true,
// 5.39
"STAKING_ETH_ENABLED" to true,
"DYNAMIC_ADDRESSES_ENABLED" to true,
"SOLANA_TX_HISTORY_ENABLED" to true,
"SOLANA_SCALED_UI_AMOUNT_ENABLED" to true,
"SWAP_AB_ENABLED" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true,
"AND_15009_SWAP_PROVIDER_FILTER_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED" to true,
"AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true,
"AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true,
"TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true,
// 5.39.2
"AND_15154_YIELD_PROMO_ENABLED" to true,
// 5.40
"TWI_1377_MANAGE_FUNDS" to true,
// 6.0
"APP_REDESIGN_ENABLED" to true,
"TWI_1326_YIELD_MODE_SWAP_ENABLED" to true,
"AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED" to true,
"AND_15120_SWAP_INTEGRATED_APPROVE" to true,
"AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED" to true,
"AND_15258_QUICK_TOP_UP_ENABLED" to true,
"AND_15368_VISA_PAY_REDESIGN" to true,
"AND_15364_VISA_PAY_CARD_CLOSE" to true,
"AND_15489_EXPRESS_SHARE_BUTTON_ENABLED" to true,
"AND_15235_VISA_MULTIPLE_CARDS" to true,
"AND_15715_SWAP_BEST_DEX_RATE_ENABLED" to true,
// 6.1
"TWI_1638_VA_MVP0_ENABLED" to true,
"TWI_1403_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED" to true,
)
)
}

View file

@ -46,6 +46,7 @@ object TestConstants {
const val ALLURE_LABEL_VALUE = "Kaspresso"
const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val COINS_API_SCENARIO = "coins_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"
@ -68,5 +69,7 @@ object TestConstants {
"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_ELIGIBILITY_CHANNELS_SCENARIO = "tangem_pay_eligibility_channels"
const val TANGEM_PAY_KYC_STATUS_SCENARIO = "tangem_pay_kyc_status"
const val TANGEM_PAY_ACCESS_CODE = "517384"
}

View file

@ -0,0 +1,20 @@
package com.tangem.common.extensions
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.performClick
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AppSettingsScreenTestTags
import com.tangem.core.ui.test.TokenDetailsTopBarTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.TopNavigationTestTags
fun BaseTestCase.tapBackButton() {
waitForIdle()
composeTestRule.onNode(
hasTestTag(TopNavigationTestTags.BACK_BUTTON)
or hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
or hasTestTag(TokenDetailsTopBarTestTags.BACK_BUTTON)
or hasTestTag(AppSettingsScreenTestTags.BACK_BUTTON),
useUnmergedTree = true,
).performClick()
}

View file

@ -0,0 +1,34 @@
package com.tangem.common.extensions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.test.assertIsDisplayed
/** Returns the first non-blank text found in this node or its subtree (unmerged tree). */
fun SemanticsNode.firstTextOrNull(): String? {
config.getOrNull(SemanticsProperties.Text)
?.firstOrNull()?.text?.takeIf { it.isNotBlank() }
?.let { return it }
children.forEach { child -> child.firstTextOrNull()?.let { return it } }
return null
}
/**
* Reads the first text of every currently displayed node in this collection, in visual order
* (top-to-bottom, then left-to-right). Non-displayed nodes and nodes without text are skipped.
*/
fun SemanticsNodeInteractionCollection.displayedTextsInVisualOrder(): List<String> {
val count = fetchSemanticsNodes().size
return (0 until count)
.mapNotNull { index ->
val interaction = get(index)
if (runCatching { interaction.assertIsDisplayed() }.isFailure) return@mapNotNull null
val node = interaction.fetchSemanticsNode()
val text = node.firstTextOrNull() ?: return@mapNotNull null
node.boundsInRoot to text
}
.sortedWith(compareBy({ it.first.top }, { it.first.left }))
.map { it.second }
}

View file

@ -42,6 +42,17 @@ object AddressComparisonHelper {
assertTrue(report, false)
}
fun derivationPathForBlockchain(json: String, blockchain: String): String {
val array = JSONArray(json)
for (i in 0 until array.length()) {
val entry = array.getJSONObject(i)
if (entry.getString("blockchain").equals(blockchain, ignoreCase = true)) {
return entry.getString("derivationPath").trim()
}
}
error("No entry with blockchain '$blockchain' found in addresses JSON")
}
private fun parseAndNormalize(json: String): List<AddressEntry> {
val array = JSONArray(json)
val entries = mutableListOf<AddressEntry>()

View file

@ -4,6 +4,8 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
fun getClipboardText(context: Context): String? {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@ -27,12 +29,15 @@ fun clearClipboard(
clipboard.clearPrimaryClip()
}
// Kotlin's assert() is a no-op on device (JVM assertions disabled) — use JUnit asserts here.
fun assertClipboardTextEquals(
expected: String,
context: Context = ApplicationProvider.getApplicationContext()
) {
assertEquals("Clipboard text mismatch", expected, getClipboardText(context))
}
fun assertClipboardIsEmpty(context: Context = ApplicationProvider.getApplicationContext()) {
val actual = getClipboardText(context)
assert(actual == expected) {
"Clipboard text mismatch.\nExpected: '$expected'\nActual: '$actual'"
}
assertTrue("Expected empty clipboard but was: '$actual'", actual.isNullOrEmpty())
}

View file

@ -1,10 +1,16 @@
package com.tangem.common.utils
import okhttp3.Call
import okhttp3.EventListener
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import com.tangem.utils.logging.TangemLogger
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
// WC URIs embed a session symKey that lets anyone join/hijack the session — strip it before logging.
@ -13,6 +19,79 @@ private val WC_SECRET_REGEX = Regex("(symKey(?:=|%3D))[^&\\s\"']+", RegexOption.
private fun redactWcSecrets(text: String): String =
WC_SECRET_REGEX.replace(text) { "${it.groupValues[1]}<redacted>" }
private const val DEFAULT_MAX_ATTEMPTS = 4
private const val INITIAL_BACKOFF_MS = 500L
/**
* Runs [block] with exponential backoff. Retries ONLY when [block] throws (transient failure:
* network error, non-2xx, malformed body). A `null` return is treated as terminal (e.g. "200 but no
* data for this key") and is NOT retried. Returns the block result, or null if all attempts failed.
*/
private fun <T> retryWithBackoff(
maxAttempts: Int = DEFAULT_MAX_ATTEMPTS,
initialDelayMs: Long = INITIAL_BACKOFF_MS,
block: (attempt: Int) -> T,
): T? {
var delayMs = initialDelayMs
var lastError: Throwable? = null
repeat(maxAttempts) { i ->
val attempt = i + 1
try {
return block(attempt)
} catch (e: Exception) {
lastError = e
TangemLogger.w("Attempt $attempt/$maxAttempts failed: ${e.message}")
if (attempt < maxAttempts) {
try {
Thread.sleep(delayMs)
} catch (ie: InterruptedException) {
Thread.currentThread().interrupt()
TangemLogger.w("Retry backoff sleep interrupted; aborting retries")
return null
}
delayMs *= 2
}
}
}
TangemLogger.e("All $maxAttempts attempts failed", lastError)
return null
}
/**
* Logs the actual connected endpoint (IPv4/IPv6) and whether a proxy is in the path. Lets CI logs
* distinguish "went out via the wrong egress / IPv6 / through a local proxy" from other failures.
*/
private val diagnosticEventListener = object : EventListener() {
override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) {
TangemLogger.i("Connecting to ${inetSocketAddress.address?.hostAddress} (proxy=$proxy)")
}
}
private fun diagnosticClient(connectSec: Long, readSec: Long, callSec: Long): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(connectSec, TimeUnit.SECONDS)
.readTimeout(readSec, TimeUnit.SECONDS)
.callTimeout(callSec, TimeUnit.SECONDS)
// Don't follow redirects: a Cloudflare Access 302 must stay visible (its `location` points at
// cloudflareaccess.com) so logHttpFailure can flag "egress IP not allow-listed". /health and
// /addresses have no legitimate redirects.
.followRedirects(false)
.followSslRedirects(false)
.eventListener(diagnosticEventListener)
.build()
/**
* Logs enough to classify a failed response at a glance: `cf-ray`/`location`/`server` reveal a
* Cloudflare Access 302 (egress IP not allow-listed) vs an origin 5xx vs anything else.
*/
private fun logHttpFailure(tag: String, response: Response, body: String) {
TangemLogger.e(
"$tag failed: code=${response.code} cf-ray=${response.header("cf-ray") ?: "-"} " +
"server=${response.header("server") ?: "-"} location=${response.header("location") ?: "-"} " +
"body=${body.take(200)}",
)
}
/**
* Requests a WalletConnect URI from the qa-tools service.
*
@ -142,50 +221,40 @@ fun getAddressesFromApi(
): String? {
TangemLogger.i("Getting addresses for seed key: $seedKey")
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.callTimeout(90, TimeUnit.SECONDS)
.build()
val client = diagnosticClient(connectSec = 30, readSec = 60, callSec = 90)
val request = Request.Builder()
.url("$baseUrl/addresses")
.get()
.build()
return try {
return retryWithBackoff { attempt ->
TangemLogger.i("Getting addresses for '$seedKey', attempt $attempt")
client.newCall(request).execute().use { response ->
TangemLogger.i("Response code: ${response.code}")
if (!response.isSuccessful) {
// Transient (network/Access 302/5xx) — throw so retryWithBackoff retries.
logHttpFailure("getAddressesFromApi", response, response.body?.string() ?: "")
throw IOException("getAddressesFromApi: HTTP ${response.code}")
}
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
val body = response.body?.string() ?: ""
val contentType = response.header("Content-Type") ?: ""
if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) {
logHttpFailure("getAddressesFromApi (not JSON)", response, body)
throw IOException("getAddressesFromApi: unexpected non-JSON response")
}
val contentType = response.header("Content-Type") ?: ""
if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) {
TangemLogger.e("Unexpected response (not JSON), Content-Type: $contentType, body: $body")
return null
}
val jsonObject = JSONObject(body)
val data = jsonObject.optJSONObject("data") ?: jsonObject
val seedData = data.optJSONArray(seedKey)
if (seedData != null) {
TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries")
seedData.toString()
} else {
TangemLogger.e("No data found for seed key: $seedKey")
null
}
val jsonObject = JSONObject(body)
val data = jsonObject.optJSONObject("data") ?: jsonObject
val seedData = data.optJSONArray(seedKey)
if (seedData != null) {
TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries")
seedData.toString()
} else {
val errorBody = response.body?.string() ?: "No error body"
TangemLogger.e("Request failed: ${response.code}, body: $errorBody")
// Terminal: server responded fine but has no data for this key — retrying won't help.
TangemLogger.e("No data found for seed key: $seedKey")
null
}
}
} catch (e: Exception) {
TangemLogger.e("Error getting addresses", e)
null
}
}
@ -194,43 +263,31 @@ fun checkServiceHealth(
): String? {
TangemLogger.i("Checking service health")
val client = OkHttpClient()
val client = diagnosticClient(connectSec = 15, readSec = 30, callSec = 45)
val request = Request.Builder()
.url("$baseUrl/health")
.get()
.build()
return try {
return retryWithBackoff { attempt ->
TangemLogger.i("Checking service health, attempt $attempt")
client.newCall(request).execute().use { response ->
TangemLogger.i("Response code: ${response.code}")
if (!response.isSuccessful) {
// Transient (network/Access 302/5xx) — throw so retryWithBackoff retries.
logHttpFailure("checkServiceHealth", response, response.body?.string() ?: "")
throw IOException("checkServiceHealth: HTTP ${response.code}")
}
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
TangemLogger.i("Response body: $body")
if (body.isEmpty()) {
TangemLogger.e("Response body is empty")
return null
}
val jsonObject = JSONObject(body)
val status = jsonObject.optString("status", "")
if (status.isNotEmpty()) {
TangemLogger.i("Got status successfully: $status")
status
} else {
TangemLogger.e("Status field is missing or empty")
null
}
val body = response.body?.string() ?: ""
val status = if (body.isEmpty()) "" else JSONObject(body).optString("status", "")
if (status.isNotEmpty()) {
TangemLogger.i("Got status successfully: $status")
status
} else {
val errorBody = response.body?.string() ?: "No error body"
TangemLogger.e("Request failed: ${response.code}, body: $errorBody")
null
// Empty/malformed body from a 2xx — treat as transient and retry.
logHttpFailure("checkServiceHealth (empty status)", response, body)
throw IOException("checkServiceHealth: missing 'status' field")
}
}
} catch (e: Exception) {
TangemLogger.e("Error checking health", e)
null
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.common.utils
import android.graphics.Bitmap
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
/** Decodes the text encoded in a QR-code [bitmap] (e.g. captured from a Compose node via captureToImage). */
fun decodeQrCode(bitmap: Bitmap): String {
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val source = RGBLuminanceSource(width, height, pixels)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val hints = mapOf(DecodeHintType.TRY_HARDER to true)
return QRCodeReader().decode(binaryBitmap, hints).text
}

View file

@ -1,6 +1,5 @@
package com.tangem.scenarios
import android.view.KeyEvent
import androidx.test.core.app.ApplicationProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
@ -57,31 +56,6 @@ fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) {
}
}
private const val TESTER_MENU_MAX_ATTEMPTS = 3
/**
* Presses 'Volume Down' twice to open tester menu.
* Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if the menu doesn't appear.
*/
private fun BaseTestCase.openTesterMenu() {
repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt ->
waitForIdle()
device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
val opened = runCatching {
onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() }
}.isSuccess
if (opened) {
TangemLogger.i("Tester menu opened on attempt ${attempt + 1}")
return
}
TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...")
}
error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts")
}
/**
* Polls [walletManagersStore] until the wallet manager count stops growing for [WALLET_MANAGERS_SETTLE_MS].

View file

@ -61,6 +61,19 @@ fun BaseTestCase.openMainScreen(
}
}
/** Opens the main screen, synchronizes addresses, and opens the details of the token with [tokenName]. */
fun BaseTestCase.openTokenDetails(tokenName: String) {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
}
@OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessCode: String = "") {
step("Click on 'Get started' button") {

View file

@ -42,7 +42,14 @@ fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, 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) {
fun BaseTestCase.selectStablecoinAsFeeToken(
coinName: String,
tokenName: String,
// Positive flows wait until 'Apply' is enabled (fee is computed & payable). Negative flows
// (insufficient balance) must NOT wait for that — 'Apply' stays disabled — so pass false and let
// the caller assert the disabled/error state itself.
expectApplyEnabled: Boolean = true,
) {
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
@ -57,6 +64,17 @@ fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String)
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Wait until the '$tokenName' fee is loaded") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendFeeSelectorBottomSheet {
networkFeeTitle.assertIsDisplayed()
feeTokenItem(tokenName).assertIsDisplayed()
if (expectApplyEnabled) applyButton.assertIsEnabled()
}
}.isSuccess
}
}
}
/**

View file

@ -1,12 +1,15 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onOrganizeTokensScreen
import io.qameta.allure.kotlin.Allure.step
import org.junit.Assert.assertEquals
fun BaseTestCase.openOrganizeTokensScreen() {
step("Swipe to 'Add & Manage' button") {
@ -19,4 +22,30 @@ fun BaseTestCase.openOrganizeTokensScreen() {
step("Click on 'Organize tokens' button in bottom sheet") {
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
}
fun BaseTestCase.getMainScreenTokensOrder(): List<String> {
var tokens: List<String> = emptyList()
step("Read displayed token titles from 'Main' screen") {
awaitSuccess(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { tokens = getDisplayedTokenTitles() }
require(tokens.isNotEmpty()) { "No token titles found on the main screen" }
}
}
return tokens
}
fun BaseTestCase.assertOrganizeTokensMatch(expectedTokens: List<String>) {
step("Open 'Organize tokens' bottom-sheet") {
onMainScreen { clickDisplayedAddAndManageButton() }
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
step("Assert 'Organize tokens' list matches the main screen order") {
onOrganizeTokensScreen {
assertEquals(expectedTokens, getDisplayedTokenTitles())
}
}
step("Return to 'Main' screen") {
onOrganizeTokensScreen { cancelButton.clickWithAssertion() }
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.onAddCustomTokenScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog
import com.tangem.screens.onManageTokensScreen
import com.tangem.screens.onWalletSettingsScreen
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import com.tangem.core.res.R as CoreResR
private fun mainAccountName(): String = getResourceString(CoreResR.string.account_main_account_title)
fun BaseTestCase.openManageTokens(accountName: String = mainAccountName()) {
openWalletSettingsScreen()
openAccountDetails(accountName)
step("Click on 'Manage tokens' button") {
onAccountDetailsScreen { manageTokensButton.clickWithAssertion() }
}
}
fun BaseTestCase.openAddCustomToken(accountName: String = mainAccountName()) {
openManageTokens(accountName)
step("Click on 'Add custom token' button") {
onManageTokensScreen { addCustomTokenButton.clickWithAssertion() }
}
}
fun BaseTestCase.addCustomTokenWithCustomDerivation(network: String, contract: String, derivationPath: String) {
step("Click on network: '$network'") {
awaitSuccess { onAddCustomTokenScreen { scrollToNetwork(network) } }
onAddCustomTokenScreen { networkRow(network).performClick() }
}
step("Enter contract address: '$contract'") {
awaitSuccess { onAddCustomTokenScreen { contractAddressField.assertExists() } }
onAddCustomTokenScreen { contractAddressField.performTextInputInChunks(contract) }
}
step("Click on 'Derivation path' field") {
awaitSuccess { onAddCustomTokenScreen { derivationSelectorField.assertExists() } }
onAddCustomTokenScreen { derivationSelectorField.performClick() }
}
step("Click on 'Custom derivation' button") {
awaitSuccess { onAddCustomTokenScreen { customDerivationButton.assertIsDisplayed() } }
onAddCustomTokenScreen { customDerivationButton.performClick() }
}
step("Enter custom derivation path: '$derivationPath'") {
awaitSuccess { onDialog { inputField.assertIsDisplayed() } }
onDialog { inputField.performTextInputInChunks(derivationPath) }
awaitSuccess { onDialog { okButton.assertIsEnabled() } }
onDialog { okButton.performClick() }
}
step("Click on 'Add token' button") {
awaitSuccess { onAddCustomTokenScreen { addTokenButton.assertIsEnabled() } }
onAddCustomTokenScreen { addTokenButton.performClick() }
}
navigateBackToMainFromManageTokens()
}
private fun BaseTestCase.navigateBackToWalletSettings() {
step("Click on 'Manage tokens' screen 'Back' button") {
waitForIdle()
onManageTokensScreen { topAppBarBackButton.performClick() }
}
step("Click on 'Account details' screen 'Back' button") {
waitForIdle()
onAccountDetailsScreen { topAppBarBackButton.performClick() }
}
}
fun BaseTestCase.assertDerivationPathsInSelector(networkToOpen: String, vararg expectedPaths: Pair<String, String>) {
step("Click on network: '$networkToOpen'") {
awaitSuccess { onAddCustomTokenScreen { scrollToNetwork(networkToOpen) } }
onAddCustomTokenScreen { networkRow(networkToOpen).performClick() }
}
step("Click on 'Derivation path' field") {
awaitSuccess { onAddCustomTokenScreen { derivationSelectorField.assertExists() } }
onAddCustomTokenScreen { derivationSelectorField.performClick() }
}
expectedPaths.forEach { (networkId, path) ->
step("Assert '$networkId' derivation path is '$path'") {
awaitSuccess { onAddCustomTokenScreen { scrollToDerivationRow(networkId) } }
onAddCustomTokenScreen { derivationPath(networkId, path).assertIsDisplayed() }
}
}
}
fun BaseTestCase.toggleTokenNetworkInManageTokens(tokenTitle: String, networkTitle: String) {
step("Click on token: '$tokenTitle'") {
awaitSuccess { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Click on '$networkTitle' switch") {
awaitSuccess { onManageTokensScreen { networkSwitch(networkTitle).assertIsDisplayed() } }
onManageTokensScreen { networkSwitch(networkTitle).performClick() }
}
}
fun BaseTestCase.navigateBackToMainFromManageTokens() {
navigateBackToWalletSettings()
step("Click on 'Wallet settings' screen 'Back' button") {
waitForIdle()
onWalletSettingsScreen { topAppBarBackButton.performClick() }
}
step("Click on 'Details' screen 'Back' button") {
waitForIdle()
onDetailsScreen { topAppBarBackButton.performClick() }
}
}

View file

@ -45,6 +45,21 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) {
}
}
fun BaseTestCase.addNewCardWalletWithoutSync(mockContent: MockContent) {
step("Click 'More' button on TopBar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
MockProvider.setMocks(mockContent)
step("Click on 'Add Wallet' button (scans a new hardware wallet)") {
onDetailsScreen { addWalletButton.clickWithAssertion() }
}
step("Assert 'Main' screen is displayed with the new wallet") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess
}
}
}
fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) {
step("Click on token '$tokenName' on the visible wallet") {
onMainScreen { clickDisplayedToken(tokenName) }
@ -86,13 +101,23 @@ fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletNa
runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess
}
}
// The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it).
step("Assert recipient wallet '$recipientWalletName' is selected") {
step("Select recipient wallet '$recipientWalletName'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess
runCatching { onAddToPortfolioScreen { selectedWalletRow.performClick() } }.isSuccess
}
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onAddToPortfolioScreen { walletOption(recipientWalletName).performClick() } }.isSuccess
}
}
step("Click on 'Add' button") {
onAddToPortfolioScreen { addButton.performClick() }
step("Select network '$token'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onAddToPortfolioScreen { networkRow.performClick() } }.isSuccess
}
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onAddToPortfolioScreen { networkOption(token).performClick() } }.isSuccess
}
}
step("Click on 'Confirm' button") {
onAddToPortfolioScreen { confirmButton.performClick() }
}
}

View file

@ -1,11 +1,58 @@
package com.tangem.scenarios
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.extensions.extractText
import com.tangem.common.utils.decodeQrCode
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onReceiveAssetsBottomSheet
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTokenReceiveQrCodeBottomSheet
import com.tangem.screens.onTokenReceiveWarningBottomSheet
import io.qameta.allure.kotlin.Allure.step
import org.junit.Assert
/** Opens the receive flow from a funded token's details via 'Add funds' → 'Receive'. */
fun BaseTestCase.openReceiveViaAddFunds() {
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Click on 'Receive' button in bottom sheet") {
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
}
}
/**
* Asserts the QR code encodes the displayed address for both address types of a two-address-type coin,
* and that the two addresses differ.
*/
fun BaseTestCase.assertQrCodesMatchForBothAddressTypes() {
step("Go to QR code bottom sheet for the first address type") {
goToQrCodeBottomSheet()
}
var firstAddress = ""
step("Assert QR code encodes the first displayed address") {
firstAddress = assertQrCodeEncodesDisplayedAddress()
}
step("Go back to the receive addresses") {
device.uiDevice.pressBack()
}
step("Switch to the second address type") {
awaitSuccess(WAIT_UNTIL_TIMEOUT_LONG) { onReceiveAssetsBottomSheet { addressesPager.assertIsDisplayed() } }
onReceiveAssetsBottomSheet { scrollToAddress(1) }
}
step("Click on 'Show QR code' button for the second address type") {
onReceiveAssetsBottomSheet { showQrCodeButton(1).clickWithAssertion() }
}
var secondAddress = ""
step("Assert QR code encodes the second displayed address") {
secondAddress = assertQrCodeEncodesDisplayedAddress()
}
step("Assert the two address types are different") {
Assert.assertNotEquals(firstAddress, secondAddress)
}
}
fun BaseTestCase.goToQrCodeBottomSheet() {
step("Assert 'Token receive warning' bottom sheet is displayed") {
@ -15,7 +62,7 @@ fun BaseTestCase.goToQrCodeBottomSheet() {
onTokenReceiveWarningBottomSheet { gotItButton.performClick() }
}
step("Click on 'Show QR code' button") {
onReceiveAssetsBottomSheet { showQrCodeButton.clickWithAssertion() }
onReceiveAssetsBottomSheet { showQrCodeButton().clickWithAssertion() }
}
}
@ -39,3 +86,16 @@ fun BaseTestCase.checkQrCodeBottomSheetScenario() {
onTokenReceiveQrCodeBottomSheet { shareButton.assertIsDisplayed() }
}
}
/** Decodes the QR code on the receive bottom sheet and asserts it encodes the displayed address; returns that address. */
fun BaseTestCase.assertQrCodeEncodesDisplayedAddress(): String {
var displayedAddress = ""
step("Assert QR code encodes the displayed address") {
onTokenReceiveQrCodeBottomSheet {
displayedAddress = address.extractText()
val decoded = decodeQrCode(captureQrCodeBitmap())
Assert.assertEquals(displayedAddress, decoded)
}
}
return displayedAddress
}

View file

@ -0,0 +1,27 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onReferralProgramScreen
import com.tangem.screens.onWalletSettingsScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.referralTakeParticipate() {
step("Open 'Details' screen") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }
}
step("Open 'Referral program' screen") {
onWalletSettingsScreen { referralProgramButton.clickWithAssertion() }
}
step("Tap 'Participate'") {
onReferralProgramScreen { participateButton.clickWithAssertion() }
}
step("Verify referral code is displayed") {
onReferralProgramScreen { personalCodeCard.assertIsDisplayed() }
}
}

View file

@ -13,24 +13,37 @@ fun BaseTestCase.checkSendWarning(
) {
val assertDisplay = if (isDisplayed) "displayed" else "not displayed"
// When we expect the warning to be absent, it may still be finishing its disappear animation right after the
// Confirm screen opens (e.g. after re-entering a valid address), so poll the Compose tree until it is actually
// gone instead of asserting once. When we expect it to be present, assert straight away.
fun assertWarning(block: () -> Unit) {
if (isDisplayed) block() else awaitSuccess(block = block)
}
step("Assert 'Send confirm screen' is displayed") {
onSendConfirmScreen {
appBarTitle.assertIsDisplayed()
}
}
step("Assert warning title is $assertDisplay") {
onSendConfirmScreen {
warningTitle(title).assertVisibility(isDisplayed)
assertWarning {
onSendConfirmScreen {
warningTitle(title).assertVisibility(isDisplayed)
}
}
}
step("Assert warning icon is $assertDisplay") {
onSendConfirmScreen {
sendWarningIcon(message).assertVisibility(isDisplayed)
assertWarning {
onSendConfirmScreen {
sendWarningIcon(message).assertVisibility(isDisplayed)
}
}
}
step("Assert warning message is $assertDisplay") {
onSendConfirmScreen {
sendWarningMessage(message).assertVisibility(isDisplayed)
assertWarning {
onSendConfirmScreen {
sendWarningMessage(message).assertVisibility(isDisplayed)
}
}
}
if (sendButtonIsDisabled)
@ -41,8 +54,10 @@ fun BaseTestCase.checkSendWarning(
}
else
step("Assert 'Send' button is enabled") {
onSendConfirmScreen {
sendButton.assertIsEnabled()
awaitSuccess {
onSendConfirmScreen {
sendButton.assertIsEnabled()
}
}
}
}

View file

@ -164,6 +164,16 @@ fun BaseTestCase.checkStoriesChanges() {
}
}
fun BaseTestCase.skipSwapStories() {
step("Skip 'Swap stories' screen if displayed") {
onSwapStoriesScreen {
if (closeButton.isDisplayedSafely()) {
closeButton.performClick()
}
}
}
}
fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) {
step("Click on 'Select fee' icon") {
onSwapTokenScreen { selectFeeIcon.performClick() }
@ -317,6 +327,9 @@ fun BaseTestCase.openSwapInTransferModeWithHotWallet(
}
private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) {
step("Collapse header") {
onMainScreen { collapseHeader() }
}
step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") {
onMainScreen { scrollToAccount(fromAccountName) }
}

View file

@ -12,6 +12,8 @@ import com.tangem.screens.tangempay.*
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openTangemPay() {
// Existing customer: callers set the `tangem_pay_eligibility` scenario to PaeraCustomer (in
// additionalBeforeSection, before this runs), which drives the checkCustomerWalletId mock -> Payment account.
step("Import hot wallet from Tangem Pay seed phrase (with access code)") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
}
@ -23,6 +25,44 @@ fun BaseTestCase.openTangemPay() {
}
}
/** Opens Tangem Pay and taps the card to reach the card management page. */
fun BaseTestCase.openTangemPayCardPage() {
openTangemPay()
step("Click on 'Card' button") {
onTangemPayMainScreen { cardButton.clickWithAssertion() }
}
step("Assert card page 'More' button is displayed") {
awaitSuccess { onTangemPayCardPageScreen { moreButton.assertIsDisplayed() } }
}
}
/** Opens the card page and taps 'Change' on the daily limit block to reach the limit setup screen. */
fun BaseTestCase.openTangemPayDailyLimitSetup() {
openTangemPayCardPage()
step("Click on 'Change' daily limit button") {
awaitSuccess { onTangemPayCardPageScreen { dailyLimitChangeButton.assertIsDisplayed() } }
onTangemPayCardPageScreen { dailyLimitChangeButton.performClick() }
}
step("Assert daily limit setup screen is displayed") {
awaitSuccess { onTangemPayDailyLimitScreen { amountField.assertIsDisplayed() } }
onTangemPayDailyLimitScreen { setLimitsButton.assertIsDisplayed() }
}
}
/** From the card page, opens the 'Replace card' reissue bottom sheet via the 'More' menu. */
fun BaseTestCase.openReissueSheet() {
step("Click on 'More' button") {
onTangemPayCardPageScreen { moreButton.clickWithAssertion() }
}
step("Click on 'Replace card' menu item") {
awaitSuccess { onTangemPayCardPageScreen { replaceCardMenuItem.assertIsDisplayed() } }
onTangemPayCardPageScreen { replaceCardMenuItem.performClick() }
}
step("Assert reissue bottom sheet is displayed") {
awaitSuccess { onTangemPayReissueSheet { confirmButton.assertIsDisplayed() } }
}
}
// Compose Test gesture — UiAutomator swipe doesn't reach Material3 PullToRefreshBox's NestedScrollConnection.
fun BaseTestCase.pullToRefreshTangemPay() {
val balance = composeTestRule.onNode(hasTestTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE))

View file

@ -0,0 +1,33 @@
package com.tangem.scenarios
import android.view.KeyEvent
import com.tangem.common.BaseTestCase
import com.tangem.screens.onTesterMenuScreen
import com.tangem.utils.logging.TangemLogger
private const val TESTER_MENU_MAX_ATTEMPTS = 3
/**
* Opens the tester menu by sending two 'Volume Down' key events in a single shell command.
* VolumeButtonDoublePressObserver needs two ACTION_DOWN within 300ms; two separate pressKeyCode
* calls are too slow, but both events in one `input keyevent` invocation arrive back-to-back within
* the window. Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if it doesn't appear.
*/
fun BaseTestCase.openTesterMenu() {
repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt ->
waitForIdle()
val volumeDown = KeyEvent.KEYCODE_VOLUME_DOWN
device.uiDevice.executeShellCommand("input keyevent $volumeDown $volumeDown")
val opened = runCatching {
onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() }
}.isSuccess
if (opened) {
TangemLogger.i("Tester menu opened on attempt ${attempt + 1}")
return
}
TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...")
}
error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts")
}

View file

@ -0,0 +1,67 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.performScrollToNode
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AddCustomTokenScreenTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
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
class AddCustomTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddCustomTokenPageObject>(semanticsProvider = semanticsProvider) {
val selectorList: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.SELECTOR_LIST)
}
fun networkRow(networkName: String): KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.networkRow(networkName))
}
@OptIn(ExperimentalTestApi::class)
fun scrollToNetwork(networkName: String) = selectorList {
performScrollToNode(withTestTag(AddCustomTokenScreenTestTags.networkRow(networkName)))
}
@OptIn(ExperimentalTestApi::class)
fun scrollToDerivationRow(networkId: String) = selectorList {
performScrollToNode(withTestTag(AddCustomTokenScreenTestTags.derivationRow(networkId)))
}
fun derivationPath(networkId: String, path: String): KNode = child {
useUnmergedTree = true
hasTestTag(AddCustomTokenScreenTestTags.derivationRow(networkId))
hasAnyDescendant(withText(path))
}
val contractAddressField: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.CONTRACT_ADDRESS_FIELD)
}
val derivationSelectorField: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.DERIVATION_SELECTOR_FIELD)
}
val customDerivationButton: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.CUSTOM_DERIVATION_BUTTON)
}
val warningNotification: KNode = child {
hasTestTag(AddCustomTokenScreenTestTags.WARNING_NOTIFICATION)
}
val addTokenButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreResR.string.common_add_token))
}
}
internal fun BaseTestCase.onAddCustomTokenScreen(function: AddCustomTokenPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -51,6 +51,11 @@ class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions
useUnmergedTree = true
}
fun titleWithTokenName(tokenTitle: String): KNode = child {
hasText(getResourceString(R.string.get_token_title, tokenTitle))
useUnmergedTree = true
}
fun userTokenWithTitle(tokenTitle: String): KNode = child {
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
hasAnyDescendant(withTestTag(TokenElementsTestTags.TOKEN_TITLE))

View file

@ -1,24 +1,53 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasClickAction
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
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
// Scoped to the bottom-sheet container: the swap token-search screen stays behind the sheet and
// would otherwise make text matches (wallet tabs, market 'Ethereum' item) ambiguous.
class AddToPortfolioPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddToPortfolioPageObject>(semanticsProvider = semanticsProvider) {
ComposeScreen<AddToPortfolioPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
fun walletName(walletName: String): KNode = child {
hasText(walletName)
useUnmergedTree = true
}
val addButton: KNode = child {
hasText(getResourceString(R.string.common_add))
/** The wallet row on the 'Add token' screen (label + selected value); tap to open the wallet selector. */
val selectedWalletRow: KNode = child {
hasText(getResourceString(R.string.wc_common_wallet))
hasClickAction()
}
/** A wallet item inside the portfolio selector screen. */
fun walletOption(walletName: String): KNode = child {
hasText(walletName)
hasClickAction()
}
/** The network row on the 'Add token' screen; tap to open the 'Choose network' selector. */
val networkRow: KNode = child {
hasText(getResourceString(R.string.wc_common_network))
hasClickAction()
}
/** A network item inside the 'Choose network' selector. */
fun networkOption(networkName: String): KNode = child {
hasText(networkName)
hasClickAction()
}
val confirmButton: KNode = child {
hasText(getResourceString(R.string.common_confirm))
hasClickAction()
}
}

View file

@ -26,9 +26,9 @@ class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions
useUnmergedTree = true
}
val addButton: KNode = child {
val confirmButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add))
hasText(getResourceString(R.string.common_confirm))
useUnmergedTree = true
}

View file

@ -14,6 +14,10 @@ class AppSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
hasTestTag(AppSettingsScreenTestTags.CURRENCY_BUTTON)
useUnmergedTree = true
}
val backButton: KNode = child {
hasTestTag(AppSettingsScreenTestTags.BACK_BUTTON)
}
}
internal fun BaseTestCase.onAppSettingsScreen(function: AppSettingsPageObject.() -> Unit) =

View file

@ -10,10 +10,15 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DetailsPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_CONTAINER)
}
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
@ -61,6 +66,12 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasAnyDescendant(withText(name))
useUnmergedTree = true
}
val getTangemPayRow: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
hasAnyDescendant(withText(getResourceString(CoreResR.string.tangempay_get_tangem_pay)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onDetailsScreen(function: DetailsPageObject.() -> Unit) =

View file

@ -2,6 +2,7 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseDialogTestTags
@ -10,6 +11,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
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 DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DialogPageObject>(semanticsProvider = semanticsProvider) {
@ -18,6 +20,12 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(BaseDialogTestTags.CONTAINER)
}
fun containerWithText(text: String): KNode = child {
hasTestTag(BaseDialogTestTags.CONTAINER)
hasAnyDescendant(withText(text = text, substring = true))
useUnmergedTree = true
}
val title: KNode = child {
hasTestTag(BaseDialogTestTags.TITLE)
}
@ -87,6 +95,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.common_ok))
}
val forgetButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreResR.string.common_forget))
}
val changeButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_change))

View file

@ -1,8 +1,11 @@
package com.tangem.screens
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.displayedTextsInVisualOrder
import com.tangem.common.extensions.firstTextOrNull
import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition
import com.tangem.common.utils.LazyListItemNode
@ -14,6 +17,7 @@ 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 kotlin.math.abs
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
@ -96,7 +100,7 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
* Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which
* pushes lower list items off-screen when the header is expanded.
*/
private fun collapseHeader() {
fun collapseHeader() {
screenContainer {
performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) }
}
@ -121,16 +125,75 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
error("Token '$tokenName' is not displayed on the current wallet page")
}
// Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen.
/**
* Switches to the previous/next wallet in the pager. A horizontal swipe is a silent no-op unless
* the collapsing balance header is fully expanded and pinned to the top, so we retry: swipe, and
* whenever the wallet identity doesn't change, expand the header and try again.
*/
fun swipeToAdjacentWallet(toPrevious: Boolean) {
val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM))
for (i in 0 until nodes.fetchSemanticsNodes().size) {
val swiped = runCatching {
nodes[i].assertIsDisplayed()
nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}.isSuccess
if (swiped) return
val before = displayedWalletIdentity()
repeat(times = WALLET_SWITCH_ATTEMPTS) {
swipeCurrentPage(toPrevious)
val now = displayedWalletIdentity()
if (now != null && now != before) return
expandCollapsingHeader()
}
error("Wallet did not switch from '$before' after $WALLET_SWITCH_ATTEMPTS attempts")
}
// Expand only *after* a swipe that didn't page: if the header is already pinned, a swipe-down here
// would trigger pull-to-refresh and un-pin it, breaking the horizontal swipe.
private fun expandCollapsingHeader() {
onScreenPage()?.performTouchInput {
swipeDown(startY = visibleSize.height * 0.3f, endY = visibleSize.height * 0.8f)
}
}
private fun swipeCurrentPage(toPrevious: Boolean) {
onScreenPage()?.performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}
// Identity = title + balance: a still-restoring wallet has no CARD_TITLE but always a WALLET_BALANCE.
private fun displayedWalletIdentity(): String? {
val title = onScreenPageChild(withTestTag(MainScreenTestTags.CARD_TITLE))?.firstText()
val balance = onScreenPageChild(withTestTag(MainScreenTestTags.WALLET_BALANCE))?.firstText()
return listOfNotNull(title, balance).joinToString(separator = "|").ifBlank { null }
}
/**
* The full-width pager-page container currently on-screen. The pager keeps adjacent pages composed
* off-screen at ±pageWidth (so [assertIsDisplayed] can't tell them apart), hence selection by
* geometry: the on-screen page is the only one whose left edge is within half a page of x=0.
*/
private fun onScreenPage(): SemanticsNodeInteraction? =
firstNodeMatching(withTestTag(MainScreenTestTags.SCREEN_CONTAINER), useUnmergedTree = false) {
abs(it.left) < it.width / 2f
}
/** A node matching [matcher] whose centre lies within the on-screen page (skips zero-size off-screen copies). */
private fun onScreenPageChild(matcher: SemanticsMatcher): SemanticsNodeInteraction? {
val page = onScreenPage()?.fetchSemanticsNode()?.boundsInRoot ?: return null
return firstNodeMatching(matcher) {
it.width > 0f && it.height > 0f && it.center.x >= page.left && it.center.x < page.right
}
}
private fun SemanticsNodeInteraction.firstText(): String? = fetchSemanticsNode().firstTextOrNull()
// Single geometry primitive behind the pager helpers: the first node matching [matcher] whose
// bounds satisfy [predicate]. Replaces the per-caller onAllNodes(...)[i] loops.
private fun firstNodeMatching(
matcher: SemanticsMatcher,
useUnmergedTree: Boolean = true,
predicate: (Rect) -> Boolean,
): SemanticsNodeInteraction? {
val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = useUnmergedTree)
repeat(times = nodes.fetchSemanticsNodes().size) { index ->
val node = nodes[index]
val matches = runCatching { predicate(node.fetchSemanticsNode().boundsInRoot) }.getOrDefault(false)
if (matches) return node
}
return null
}
val restoringProgressText: KNode = child {
@ -139,12 +202,13 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
}
val walletImportedBanner: KNode = child {
hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)
hasTestTag(NotificationTestTags.TITLE)
hasText(getResourceString(CoreResR.string.initial_wallet_sync_banner_title))
useUnmergedTree = true
}
val walletImportedBannerCheckHereButton: KNode = child {
hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER))
hasAnyAncestor(withTestTag(NotificationTestTags.CONTAINER))
hasText(getResourceString(CoreResR.string.main_manage_tokens))
useUnmergedTree = true
}
@ -201,6 +265,12 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
val getTangemPayBanner: KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(getResourceString(CoreResR.string.tangempay_onboarding_banner_title))
useUnmergedTree = true
}
val devCardNotificationIcon: KNode = child {
hasAnySibling(withText(getResourceString(R.string.warning_developer_card_title)))
hasTestTag(NotificationTestTags.ICON)
@ -373,6 +443,23 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
/** Collapses the header, scrolls to and clicks the 'Add & manage' button on the on-screen wallet page. */
@OptIn(ExperimentalTestApi::class)
fun clickDisplayedAddAndManageButton() {
val container = onScreenPage() ?: error("No on-screen wallet page found")
// Best-effort: the button is a footer outside the scrollable list, so performScrollToNode can
// throw when it's already visible — that must not abort the click below.
runCatching {
container.performTouchInput {
swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f)
}
container.performScrollToNode(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
}
val button = onScreenPageChild(withTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON))
?: error("'Add & manage' button is not displayed on the current wallet page")
button.performClick()
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
@ -478,6 +565,21 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}.assertIsDisplayed()
}
/**
* Token titles displayed on the current wallet page, in visual order. Reads the
* [TokenElementsTestTags.TOKEN_TITLE] rows so network group headers (which share the
* TOKEN_LIST_ITEM tag) are excluded, keeping the result symmetric with the 'Organize tokens' reader.
*/
fun getDisplayedTokenTitles(): List<String> =
semanticsProvider.onAllNodes(
withTestTag(TokenElementsTestTags.TOKEN_TITLE),
useUnmergedTree = true,
).displayedTextsInVisualOrder()
private companion object {
const val WALLET_SWITCH_ATTEMPTS = 4
}
}
internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) =

View file

@ -6,6 +6,7 @@ import com.tangem.common.R
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.SearchBarTestTags
import com.tangem.core.ui.test.SwitchTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
@ -17,6 +18,7 @@ import androidx.compose.ui.test.hasText as withText
import androidx.compose.ui.test.hasAnySibling as withAnySibling
import androidx.compose.ui.test.hasAnyDescendant as withAnyDescendant
import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
import com.tangem.core.res.R as CoreResR
class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) {
@ -35,11 +37,43 @@ class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}
val searchClearButton: KNode = child {
hasTestTag(SearchBarTestTags.CLEAR_BUTTON)
}
val addCustomTokenButton: KNode = child {
hasTestTag(TopAppBarTestTags.MORE_BUTTON)
}
fun tokenItem(tokenName: String): KNode = child {
hasTestTag(ManageTokensScreenTestTags.TOKEN_ITEM)
hasText(tokenName)
}
fun networkStandard(networkName: String, standard: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(
withText(standard).and(
withAnyAncestor(
withTestTag(ManageTokensScreenTestTags.NETWORK_NAME)
.and(withAnyDescendant(withText(networkName, ignoreCase = true))),
),
),
)
}
fun networkName(networkName: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(
withTestTag(ManageTokensScreenTestTags.NETWORK_NAME)
.and(withAnyDescendant(withText(networkName))),
)
}
val contractAddressCopiedMessage: KNode = child {
hasText(getResourceString(CoreResR.string.contract_address_copied_message))
}
fun networkSwitch(networkName: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(

View file

@ -1,8 +1,12 @@
package com.tangem.screens
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyDescendant
import androidx.compose.ui.test.performTouchInput
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.displayedTextsInVisualOrder
import com.tangem.common.extensions.hasLazyListItemPosition
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
@ -14,12 +18,13 @@ 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.hasAnyAncestor as withAnyAncestor
import androidx.compose.ui.test.hasAnyChild as withAnyChild
import androidx.compose.ui.test.hasAnySibling as withAnySibling
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
class OrganizeTokensPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<OrganizeTokensPageObject>(semanticsProvider = semanticsProvider) {
// region TopBar
@ -93,6 +98,15 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
}
}
// Read TOKEN_TITLE nodes, not the outer TOKEN_LIST_ITEM: the item container here doesn't merge its
// descendants, so the nested title Text never surfaces on the item node's semantics.
fun getDisplayedTokenTitles(): List<String> =
semanticsProvider.onAllNodes(
withTestTag(TokenElementsTestTags.TOKEN_TITLE) and
withAnyAncestor(withTestTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST)),
useUnmergedTree = true,
).displayedTextsInVisualOrder()
fun tokenDraggableButton(tokenTitle: String): KNode {
return lazyList.child {
hasTestTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE)
@ -108,6 +122,47 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
)
}
}
// Reorder by synthesizing a drag on the source row's handle: down, hold, step to the destination
// centre, lift — a single `swipe` won't engage the reorder detector. Only valid within one network
// group (isValidDropTarget), so source and destination must share a group.
fun dragToken(source: String, destination: String) {
val sourceHandle = semanticsProvider.onNode(dragHandleMatcher(source), useUnmergedTree = true)
val sourceNode = sourceHandle.fetchSemanticsNode()
val destinationNode = semanticsProvider
.onNode(tokenItemMatcher(destination), useUnmergedTree = true)
.fetchSemanticsNode()
// performTouchInput coordinates are local to the source node; express both endpoints there.
val origin = sourceNode.positionInRoot
val start = sourceNode.boundsInRoot.center - origin
val end = destinationNode.boundsInRoot.center - origin
sourceHandle.performTouchInput {
down(start)
advanceEventTime(DRAG_HOLD_MS)
repeat(times = DRAG_STEPS) { step ->
moveTo(lerp(start = start, stop = end, fraction = (step + 1).toFloat() / DRAG_STEPS))
advanceEventTime(DRAG_STEP_MS)
}
up()
}
}
// The drag handle sits under an untagged wrapper inside TOKEN_NON_FIAT_BLOCK, so anchor on the
// enclosing TOKEN_LIST_ITEM (one handle + one title per item) instead of the direct parent.
private fun dragHandleMatcher(tokenTitle: String): SemanticsMatcher =
withTestTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE) and
withAnyAncestor(tokenItemMatcher(tokenTitle))
private fun tokenItemMatcher(tokenTitle: String): SemanticsMatcher =
withTestTag(OrganizeTokensScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenTitle))
private companion object {
const val DRAG_HOLD_MS = 200L
const val DRAG_STEPS = 16
const val DRAG_STEP_MS = 16L
}
}
internal fun BaseTestCase.onOrganizeTokensScreen(function: OrganizeTokensPageObject.() -> Unit) =

View file

@ -1,8 +1,10 @@
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.R
import com.tangem.core.ui.test.TokenReceiveAssetsBottomSheetTestTags
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
@ -11,10 +13,23 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class ReceiveAssetsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ReceiveAssetsBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val showQrCodeButton: KNode = child {
/** 'Show QR code' button of the address at [index]; both pager cards stay composed, so match by position. */
fun showQrCodeButton(index: Int = 0): KNode = child {
hasText(getResourceString(R.string.token_receive_show_qr_code_title))
hasPosition(index)
useUnmergedTree = true
}
val addressesPager: KNode = child {
hasTestTag(TokenReceiveAssetsBottomSheetTestTags.ADDRESSES_PAGER)
useUnmergedTree = true
}
/** Pages the addresses carousel to the address of the given [index]. */
@OptIn(ExperimentalTestApi::class)
fun scrollToAddress(index: Int) {
addressesPager { performScrollToIndex(index) }
}
}
internal fun BaseTestCase.onReceiveAssetsBottomSheet(function: ReceiveAssetsBottomSheetPageObject.() -> Unit) =

View file

@ -78,6 +78,11 @@ class ReferralProgramPageObject(semanticsProvider: SemanticsNodeInteractionsProv
hasTestTag(BaseButtonTestTags.TEXT)
useUnmergedTree = true
}
val personalCodeCard: KNode = child {
hasTestTag(ReferralProgramScreenTestTags.PERSONAL_CODE_CARD)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onReferralProgramScreen(function: ReferralProgramPageObject.() -> Unit) =

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.features.tokendetails.impl.R
import com.tangem.core.res.R as CoreResR
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
@ -62,6 +63,33 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasText(getResourceString(R.string.staking_enabled))
}
val yieldSupplyAvailableBlock: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.YIELD_SUPPLY_AVAILABLE_BLOCK)
useUnmergedTree = true
}
val yieldSupplyActiveBlock: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.YIELD_SUPPLY_BLOCK)
useUnmergedTree = true
}
val earnBlockTitleIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.EARN_BLOCK_TITLE_ICON)
useUnmergedTree = true
}
val yieldModeConnectedTitle: KNode = child {
hasAnyAncestor(withTestTag(TokenDetailsScreenTestTags.YIELD_SUPPLY_BLOCK))
hasText(getResourceString(CoreResR.string.yield_module_transaction_enter))
useUnmergedTree = true
}
fun yieldModeApy(apy: String): KNode = child {
hasAnyAncestor(withTestTag(TokenDetailsScreenTestTags.YIELD_SUPPLY_BLOCK))
hasText(getResourceString(CoreResR.string.yield_module_average_apy, apy))
useUnmergedTree = true
}
val title: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE)
}
@ -90,6 +118,11 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
/** 'Receive' row of the zero-balance actions block (Buy / Swap / Receive), shown instead of the action buttons. */
val receiveButton: KNode = child {
hasText(getResourceString(R.string.common_receive))
}
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
hasAnySibling(withText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCurrencyName)))
hasTestTag(NotificationTestTags.ICON)
@ -114,6 +147,12 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
fun tokenTitle(name: String): KNode = child {
hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE)
hasAnyDescendant(withText(text = name, substring = true))
useUnmergedTree = true
}
fun networkFeeNotificationMessage(
currencyName: String,
networkName: String,

View file

@ -0,0 +1,40 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenMarketBlockTestTags
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 TokenMarketBlockPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TokenMarketBlockPageObject>(semanticsProvider = semanticsProvider) {
val block: KNode = child {
hasTestTag(TokenMarketBlockTestTags.BLOCK)
useUnmergedTree = true
}
val title: KNode = child {
hasTestTag(TokenMarketBlockTestTags.TITLE)
useUnmergedTree = true
}
val price: KNode = child {
hasTestTag(TokenMarketBlockTestTags.PRICE)
useUnmergedTree = true
}
val priceChange: KNode = child {
hasTestTag(TokenMarketBlockTestTags.PRICE_CHANGE)
useUnmergedTree = true
}
val chart: KNode = child {
hasTestTag(TokenMarketBlockTestTags.CHART)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTokenMarketBlock(function: TokenMarketBlockPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -1,11 +1,14 @@
package com.tangem.screens
import android.graphics.Bitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.captureToImage
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.TokenReceiveQrCodeBottomSheetTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.intercept.operation.ComposeOperationType
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
@ -40,16 +43,25 @@ class TokenReceiveQrCodeBottomSheetPageObject(semanticsProvider: SemanticsNodeIn
}
val copyButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_copy))
useUnmergedTree = true
hasClickAction()
}
val shareButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_share))
useUnmergedTree = true
hasClickAction()
}
/** Captures the QR code node as a bitmap via the Kakao node delegate (no raw composeTestRule access). */
fun captureQrCodeBitmap(): Bitmap {
lateinit var bitmap: Bitmap
qrCode.delegate.perform(QrCodeAction.CAPTURE) {
bitmap = captureToImage().asAndroidBitmap()
}
return bitmap
}
private enum class QrCodeAction : ComposeOperationType { CAPTURE }
}
internal fun BaseTestCase.onTokenReceiveQrCodeBottomSheet(function: TokenReceiveQrCodeBottomSheetPageObject.() -> Unit) =

View file

@ -3,7 +3,6 @@ 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.BaseButtonTestTags
import com.tangem.core.ui.test.TokenReceiveWarningBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -18,8 +17,11 @@ class TokenReceiveWarningBottomSheetPageObject(semanticsProvider: SemanticsNodeI
}
val gotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_got_it))
}
fun networkName(name: String): KNode = child {
hasText(text = name, substring = true)
useUnmergedTree = true
}
}

View file

@ -31,6 +31,16 @@ class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED)
useUnmergedTree = true
}
fun transactionUnconfirmedStatus(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.STATUS_UNCONFIRMED)
useUnmergedTree = true
}
fun transactionAddress(title: String, address: String): KNode = transactionItem(title).child {
hasText(text = address, substring = true)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) =

View file

@ -0,0 +1,36 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.test.YieldSupplyTestTags
import com.tangem.core.res.R as CoreResR
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 YieldSupplyActivePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<YieldSupplyActivePageObject>(semanticsProvider = semanticsProvider) {
val stopEarningButton: KNode = child {
hasTestTag(YieldSupplyTestTags.STOP_EARNING_BUTTON)
useUnmergedTree = true
}
val approveButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(CoreResR.string.yield_module_approve_needed_notification_cta))
useUnmergedTree = true
}
fun notificationTitle(title: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(title)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onYieldSupplyActiveScreen(function: YieldSupplyActivePageObject.() -> 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.YieldSupplyTestTags
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 YieldSupplyApprovePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<YieldSupplyApprovePageObject>(semanticsProvider = semanticsProvider) {
val confirmButton: KNode = child {
hasTestTag(YieldSupplyTestTags.APPROVE_CONFIRM_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onYieldSupplyApproveScreen(function: YieldSupplyApprovePageObject.() -> 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.YieldSupplyTestTags
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 YieldSupplyPromoPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<YieldSupplyPromoPageObject>(semanticsProvider = semanticsProvider) {
val continueButton: KNode = child {
hasTestTag(YieldSupplyTestTags.PROMO_CONTINUE_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onYieldSupplyPromoScreen(function: YieldSupplyPromoPageObject.() -> 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.YieldSupplyTestTags
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 YieldSupplyStartEarningPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<YieldSupplyStartEarningPageObject>(semanticsProvider = semanticsProvider) {
val startEarningButton: KNode = child {
hasTestTag(YieldSupplyTestTags.START_EARNING_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onYieldSupplyStartEarningScreen(function: YieldSupplyStartEarningPageObject.() -> 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.YieldSupplyTestTags
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 YieldSupplyStopEarningPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<YieldSupplyStopEarningPageObject>(semanticsProvider = semanticsProvider) {
val confirmButton: KNode = child {
hasTestTag(YieldSupplyTestTags.STOP_EARNING_CONFIRM_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onYieldSupplyStopEarningScreen(function: YieldSupplyStopEarningPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -17,7 +17,7 @@ class TangemPayAddFundsSheetPageObject(semanticsProvider: SemanticsNodeInteracti
}
val receiveOption: KNode = child {
hasText(getResourceString(CoreResR.string.common_receive))
hasText(getResourceString(CoreResR.string.tangempay_topup_receive_title))
useUnmergedTree = true
}

View file

@ -2,14 +2,39 @@ package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.test.TangemPayTestTags
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 TangemPayCardPagePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayCardPagePageObject>(semanticsProvider = semanticsProvider) {
val moreButton: KNode = child {
hasTestTag(TangemPayTestTags.CARD_PAGE_MORE_BUTTON)
useUnmergedTree = true
}
val replaceCardMenuItem: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_card_details_reissue_card))
useUnmergedTree = true
}
val cardNumberShort: KNode = child {
hasTestTag(TangemPayTestTags.CARD_NUMBER_SHORT)
useUnmergedTree = true
}
val reissueInProgressBlock: KNode = child {
hasText(
text = getResourceString(CoreResR.string.tangempay_reissue_card_in_progress),
substring = true,
)
useUnmergedTree = true
}
val changePinRow: KNode = child {
hasTestTag(TangemPayTestTags.CHANGE_PIN_ROW)
useUnmergedTree = true
@ -64,6 +89,16 @@ class TangemPayCardPagePageObject(semanticsProvider: SemanticsNodeInteractionsPr
hasTestTag(TangemPayTestTags.CARD_DETAILS_COPY_CVC)
useUnmergedTree = true
}
val dailyLimitChangeButton: KNode = child {
hasTestTag(TangemPayTestTags.DAILY_LIMIT_CHANGE_BUTTON)
useUnmergedTree = true
}
val dailyLimitValue: KNode = child {
hasTestTag(TangemPayTestTags.DAILY_LIMIT_CURRENT_VALUE)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayCardPageScreen(function: TangemPayCardPagePageObject.() -> Unit) =

View file

@ -0,0 +1,47 @@
package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.SendScreenTestTags
import com.tangem.core.ui.test.TangemPayTestTags
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 TangemPayDailyLimitPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayDailyLimitPageObject>(semanticsProvider = semanticsProvider) {
// AmountTextField tags its editable field with SendScreenTestTags.INPUT_TEXT_FIELD; it's the only one here.
val amountField: KNode = child {
hasTestTag(SendScreenTestTags.INPUT_TEXT_FIELD)
useUnmergedTree = true
}
val hint: KNode = child {
hasTestTag(TangemPayTestTags.DAILY_LIMIT_HINT)
useUnmergedTree = true
}
val setLimitsButton: KNode = child {
hasTestTag(TangemPayTestTags.DAILY_LIMIT_SET_BUTTON)
useUnmergedTree = true
}
val successTitle: KNode = child {
hasTestTag(TangemPayTestTags.DAILY_LIMIT_SUCCESS_TITLE)
useUnmergedTree = true
}
val doneButton: KNode = child {
hasTestTag(TangemPayTestTags.DAILY_LIMIT_DONE_BUTTON)
useUnmergedTree = true
}
fun presetChip(rawValue: String): KNode = child {
hasTestTag(TangemPayTestTags.dailyLimitPresetChip(rawValue))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayDailyLimitScreen(function: TangemPayDailyLimitPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,33 @@
package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.WarningBottomSheetTestTags
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 TangemPayKycSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayKycSheetPageObject>(semanticsProvider = semanticsProvider) {
fun title(text: String): KNode = child {
hasTestTag(WarningBottomSheetTestTags.TITLE)
hasText(text)
useUnmergedTree = true
}
fun primaryButtonWithText(text: String): KNode = child {
hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY)
hasAnyDescendant(withText(text))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasTestTag(WarningBottomSheetTestTags.CLOSE_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayKycSheet(function: TangemPayKycSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -4,6 +4,7 @@ import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.core.ui.test.TokenDetailsTopBarTestTags
import com.tangem.core.res.R as CoreResR
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -19,6 +20,12 @@ class TangemPayMainPageObject(semanticsProvider: SemanticsNodeInteractionsProvid
useUnmergedTree = true
}
fun tileWithSubtitle(subtitle: String): KNode = child {
hasTestTag(TangemPayTestTags.MAIN_SCREEN_TILE)
hasAnyDescendant(withText(subtitle))
useUnmergedTree = true
}
val balance: KNode = child {
hasTestTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE)
useUnmergedTree = true
@ -41,10 +48,27 @@ class TangemPayMainPageObject(semanticsProvider: SemanticsNodeInteractionsProvid
useUnmergedTree = true
}
val moreActionsButton: KNode = child {
hasTestTag(TokenDetailsTopBarTestTags.MORE_BUTTON)
useUnmergedTree = true
}
val termsAndFeesMenuItem: KNode = child {
hasText(getResourceString(CoreResR.string.tangem_pay_terms_limits))
}
fun transactionRowWithText(text: String): KNode = child {
hasText(text)
useUnmergedTree = true
}
val reissueInProgressBanner: KNode = child {
hasText(
text = getResourceString(CoreResR.string.tangempay_reissue_card_in_progress),
substring = true,
)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayMainScreen(function: TangemPayMainPageObject.() -> Unit) =

View file

@ -0,0 +1,21 @@
package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.res.R as CoreResR
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 TangemPayOnboardingPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayOnboardingPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_onboarding_title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayOnboardingScreen(function: TangemPayOnboardingPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,62 @@
package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.test.TangemPayTestTags
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 TangemPayReissueSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayReissueSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_reissue_card_title))
useUnmergedTree = true
}
val description: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_reissue_card_description))
useUnmergedTree = true
}
val feeLabel: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_reissue_card_fee_label))
useUnmergedTree = true
}
val feeValue: KNode = child {
hasTestTag(TangemPayTestTags.REISSUE_SHEET_FEE_VALUE)
useUnmergedTree = true
}
val confirmButton: KNode = child {
hasTestTag(TangemPayTestTags.REISSUE_SHEET_CONFIRM_BUTTON)
useUnmergedTree = true
}
val feeErrorTitle: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_reissue_card_fee_unreachable_error_title))
useUnmergedTree = true
}
val refreshButton: KNode = child {
hasText(getResourceString(CoreResR.string.warning_button_refresh))
useUnmergedTree = true
}
val insufficientFundsTitle: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_reissue_card_insufficient_funds_title))
useUnmergedTree = true
}
val addFundsButton: KNode = child {
hasText(getResourceString(CoreResR.string.tangempay_card_details_add_funds))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayReissueSheet(function: TangemPayReissueSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,36 @@
package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.WarningBottomSheetTestTags
import com.tangem.core.res.R as CoreResR
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 TangemPayServiceUnavailableSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayServiceUnavailableSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(WarningBottomSheetTestTags.TITLE)
hasText(getResourceString(CoreResR.string.tangempay_service_unavailable_title))
useUnmergedTree = true
}
val description: KNode = child {
hasTestTag(WarningBottomSheetTestTags.MESSAGE)
hasText(getResourceString(CoreResR.string.tangempay_service_unavailable_description))
useUnmergedTree = true
}
// Merged tree: BUTTON_PRIMARY tags the button, its label is a child — only the merged node has both.
val gotItButton: KNode = child {
hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY)
hasText(getResourceString(CoreResR.string.common_got_it))
}
}
internal fun BaseTestCase.onTangemPayServiceUnavailableSheet(
function: TangemPayServiceUnavailableSheetPageObject.() -> Unit,
) = onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,33 @@
package com.tangem.screens.tangempay
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.test.TangemPayTestTags
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 TangemPayTransactionDetailsSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TangemPayTransactionDetailsSheetPageObject>(semanticsProvider = semanticsProvider) {
val feeTitle: KNode = child {
hasText(getResourceString(CoreResR.string.tangem_pay_fee_title))
useUnmergedTree = true
}
val serviceFeesCategory: KNode = child {
hasText(getResourceString(CoreResR.string.tangem_pay_fee_subtitle))
useUnmergedTree = true
}
val amount: KNode = child {
hasTestTag(TangemPayTestTags.TRANSACTION_DETAILS_AMOUNT)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTangemPayTransactionDetailsSheet(
function: TangemPayTransactionDetailsSheetPageObject.() -> Unit,
) = onComposeScreen(composeTestRule, function)

View file

@ -59,15 +59,15 @@ class AppCurrencyTest : BaseTestCase() {
onAppSettingsScreen { currencyButton.assertIsDisplayed() }
}
}
step("Return to 'Details' screen") {
waitForIdle()
device.uiDevice.pressBack()
step("Return to 'Details' screen via 'Back' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onAppSettingsScreen { backButton.clickWithAssertion() }
onDetailsScreen { screenContainer.assertIsDisplayed() }
}
}
step("Return to 'Main' screen via 'Back' button") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Main' screen is opened") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
onMainScreen { screenContainer.assertIsDisplayed() }
}
}

View file

@ -21,11 +21,13 @@ 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.Ignore
import org.junit.Test
@HiltAndroidTest
class BlockchainTest : BaseTestCase() {
@Ignore("[REDACTED_JIRA]")
@AllureId("3644")
@DisplayName("ADA: Checking the min amount/change = 1ADA")
@Test

View file

@ -9,6 +9,7 @@ import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.domain.models.scan.ProductType
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import com.tangem.scenarios.checkFailedTransactionDialog
@ -196,4 +197,68 @@ class FeedbackTest : BaseTestCase() {
}
}
}
@AllureId("3960")
@DisplayName("Send feedback: Failed card scanning on Details screen")
@Test
fun sendFeedbackFailedScanningOnDetails() {
val gmailText = "Welcome to Gmail"
setupHooks(
additionalAfterSection = {
device.uiDevice.pressBack()
MockProvider.resetEmulateError()
}
).run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Details screen'") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Set scanning error (the next card scan will fail with TagLost)") {
MockProvider.setEmulateError(TangemSdkError.TagLost())
}
step("Click 'Add new wallet' button") {
onDetailsScreen { addWalletButton.clickWithAssertion() }
}
step("Force show 'Scan warning' dialog"){
waitForIdle()
runOnUiThread {
MainScope().launch {
scanFailsRequester.show(AnalyticsParam.ScreensSources.Main)
}
}
}
step("Check 'Scan warning' dialog") {
waitForIdle()
checkScanWarningDialog()
}
step("Click on 'Request support' button") {
onScanWarningDialog { requestSupportButton.performClick() }
}
step("Assert 'Gmail' app is open") {
ThirdPartyAppPageObject { assertElementWithTextExists(gmailText) }
}
}
}
@AllureId("3603")
@DisplayName("Send feedback: S2C card has Contact support option")
@Test
fun sendFeedbackForS2CTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Start2Coin)
}
step("Open 'Details screen'") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Verify 'Contact support' button is displayed") {
onDetailsScreen { contactSupportButton.assertIsDisplayed() }
}
}
}
}

View file

@ -23,9 +23,9 @@ class SendTest : BaseTestCase() {
val currencyName = "USDC"
val feeCurrencyName = "Solana"
val feeCurrencySymbol = "SOL"
val balanceScenarioName = "solana_balance"
val balanceScenarioName = "solana_get_account_info_recipient"
val tokensScenarioName = "user_tokens_api"
val balanceState = "Empty"
val balanceState = "ZeroBalance"
val tokensState = "SolanaUSDC"
setupHooks(

View file

@ -503,6 +503,9 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Click on 'Swap' button") {
onMainScreen { swapButton.performClick() }
}
step("Skip stories if displayed") {
skipSwapStories()
}
step("Check 'Action is unavailable' dialog") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
checkActionIsUnavailableDialog()

View file

@ -1,25 +1,42 @@
package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase
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
import com.tangem.common.constants.TestConstants.XRP_RECIPIENT_ADDRESS
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.pullToRefresh
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.enterAmountAndOpenSendConfirm
import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSendFromTokenDetails
import com.tangem.scenarios.openSendScreenWithHotWallet
import com.tangem.scenarios.openSendSuccessScreenViaLongClickOnSendButton
import com.tangem.scenarios.readNetworkFeeAmount
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.scenarios.waitUntilNetworkFeeIsStable
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSendSuccessScreen
import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
import com.tangem.screens.onTxHistoryScreen
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.Ignore
import org.junit.Test
@HiltAndroidTest
@ -28,6 +45,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
@AllureId("594")
@DisplayName("Action buttons (token details screen): validate UI")
@Test
@Ignore("[REDACTED_JIRA]")
fun actionButtonsValidateUiTest() {
val tokenTitle = "Bitcoin"
@ -110,10 +128,13 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Get $tokenTitle' bottom-sheet is displayed") {
onAddFundsBottomSheet { titleWithTokenName(tokenTitle).assertIsDisplayed() }
}
step("Assert 'Buy' button in bottom sheet is enabled") {
onAddFundsBottomSheet { buyButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
step("Assert 'Swap' button in bottom sheet is not enabled") {
onAddFundsBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Receive' button in bottom sheet is enabled") {
@ -257,4 +278,146 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
}
}
}
@AllureId("4465")
@DisplayName("Action buttons (token details screen): 'Send' blocked while a transaction is active, works after completion")
@Test
fun sendBlockedWhileTransactionActiveTest() {
val tokenName = "XRP Ledger"
val amount = "1"
val userTokensState = "XRPHotWalletSvS"
val quotesState = "Ripple"
val startedState = "Started"
val rippleAccountInfoScenario = "ripple_account_info"
val pendingSendMessagePrefix =
getResourceString(R.string.token_button_unavailability_reason_pending_transaction_send).substringBefore("%")
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(rippleAccountInfoScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$startedState'") {
setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = startedState)
}
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$amount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = amount, recipientAddress = XRP_RECIPIENT_ADDRESS)
}
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 transfer bottom sheet") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button is not enabled while the transaction is active") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTransferBottomSheet { sendButton.assertIsNotEnabled() }
}
}
step("Click on 'Send' button") {
onTransferBottomSheet { sendButton.performClick() }
}
step("Assert pending-transaction notification dialog is displayed") {
onDialog { containerWithText(pendingSendMessagePrefix).assertIsDisplayed() }
}
step("Assert 'Send' screen is not opened") {
onSendScreen { amountInputTextField.assertDoesNotExist() }
}
// Tapping the 'Send' row dismisses the transfer bottom sheet (onActionDispatched) before the dialog shows.
step("Close the notification dialog") {
onDialog { okButton.clickWithAssertion() }
}
step("Pull to refresh to complete the active transaction") {
pullToRefresh()
}
step("Open the transfer bottom sheet again") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button is enabled after the transaction is completed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTransferBottomSheet { sendButton.assertIsEnabled() }
}
}
step("Click on 'Send' button") {
onTransferBottomSheet { sendButton.performClick() }
}
step("Assert 'Send' screen is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendScreen { amountInputTextField.assertIsDisplayed() }
}
}
}
}
@AllureId("10209")
@DisplayName("Action buttons (token details screen): 'Send' unavailable for a zero-balance token with an active transaction")
@Test
fun sendUnavailableForZeroBalanceWithActiveTransactionTest() {
val tokenName = "Dogecoin"
val zeroBalanceState = "ZeroBalance"
val activeTxHistoryState = "UnconfirmedOutgoing"
val balanceScenarioName = "dogecoin_balance"
val txHistoryScenarioName = "dogecoin_tx_history"
val sendingTitle = getResourceString(R.string.common_sending)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(balanceScenarioName)
resetWireMockScenarioState(txHistoryScenarioName)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$balanceScenarioName' to state: '$zeroBalanceState'") {
setWireMockScenarioState(scenarioName = balanceScenarioName, state = zeroBalanceState)
}
step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$activeTxHistoryState'") {
setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = activeTxHistoryState)
}
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("Assert active outgoing '$sendingTitle' transaction block is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(sendingTitle).assertIsDisplayed() }
}
}
step("Assert 'Transfer' button is not displayed for the zero-balance token") {
onTokenDetailsScreen { transferButton.assertIsNotDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.addFunds
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddTokenBottomSheet
@ -11,6 +12,7 @@ import com.tangem.screens.onBuyTokenDetailsScreen
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.Issue
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@ -66,8 +68,8 @@ class BuyTest : BaseTestCase() {
step("Click on $token in Trending list") {
onAddFundsBottomSheet { trendingTokenWithTitle(token).clickWithAssertion() }
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.clickWithAssertion() }
step("Click on 'Confirm' button") {
onAddTokenBottomSheet { confirmButton.clickWithAssertion() }
}
step("Close 'Get token' screen") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
@ -83,4 +85,24 @@ class BuyTest : BaseTestCase() {
}
}
}
@AllureId("3613")
@DisplayName("On-ramp Buy: S2C card doesn't have Buy and Sell options")
@Test
@Issue("[REDACTED_TASK_KEY]")
fun buyAndSellIsNotAvailableForS2CCardTest() {
setupHooks().run {
step("Open 'Main' screen") {
openMainScreen(productType = ProductType.Start2Coin)
}
step("Verify 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Verify Buy/Sell action buttons are hidden") {
onMainScreen {
buyButton.assertDoesNotExist()
}
}
}
}
}

View file

@ -86,9 +86,9 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Click on 'Add' button in 'Markets' bottom sheet") {
onMarketsScreen { addButton.clickWithAssertion() }
}
step("Click on 'Add' button in 'Add token' bottom sheet") {
step("Click on 'Confirm' button in 'Add token' bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onAddTokenBottomSheet { addButton.performClick() }
onAddTokenBottomSheet { confirmButton.performClick() }
}
}
step("Press 'Back' button") {

View file

@ -0,0 +1,274 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.COINS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.performTextInputInChunks
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.addCustomTokenWithCustomDerivation
import com.tangem.scenarios.assertDerivationPathsInSelector
import com.tangem.scenarios.navigateBackToMainFromManageTokens
import com.tangem.scenarios.openAddCustomToken
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddCustomTokenScreen
import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.Wallet1LegacyDerivationMockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2NoEd25519Slip0010MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertFalse
import org.junit.Test
@HiltAndroidTest
class AddCustomTokenTest : BaseTestCase() {
private val richState = "ManageTokensRich"
private val tokenTitle = "Tether"
private val ethereumNetwork = "Ethereum"
private val solanaNetwork = "Solana"
private val bitcoinNetworkId = "bitcoin"
private val ethereumClassicNetworkId = "ethereum-classic"
private val ethContract = "0xdac17f958d2ee523a2206206994597c13d831ec7"
private val solanaContract = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
private val customDerivationPath = "m/44'/60'/0'/0/1"
@AllureId("772")
@DisplayName("Add custom token: added token appears on Main")
@Test
fun addCustomTokenAppearsOnMainTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") {
flakySafely { onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } }
synchronizeAddresses(assertBalance = false)
}
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Click on network: '$ethereumNetwork'") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(ethereumNetwork) } }
onAddCustomTokenScreen { networkRow(ethereumNetwork).performClick() }
}
step("Enter contract address: '$ethContract'") {
flakySafely { onAddCustomTokenScreen { contractAddressField.assertExists() } }
// Chunked input spans several validation ticks — the model drops the first sampled form change.
onAddCustomTokenScreen { contractAddressField.performTextInputInChunks(ethContract) }
}
step("Click on 'Add token' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onAddCustomTokenScreen { addTokenButton.assertIsEnabled() } }
onAddCustomTokenScreen { addTokenButton.performClick() }
}
step("Navigate back to 'Main Screen'") { navigateBackToMainFromManageTokens() }
step("Assert token: '$tokenTitle' is displayed on Main") {
flakySafely { onMainScreen { tokenWithTitleAndAddress(tokenTitle).assertIsDisplayed() } }
}
}
}
@AllureId("775")
@DisplayName("Add custom token: custom derivation path is accepted")
@Test
fun addCustomTokenWithCustomDerivationTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") {
flakySafely { onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } }
synchronizeAddresses(assertBalance = false)
}
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Add custom token with custom derivation on '$ethereumNetwork'") {
addCustomTokenWithCustomDerivation(
network = ethereumNetwork,
contract = ethContract,
derivationPath = customDerivationPath,
)
}
step("Assert token: '$tokenTitle' is displayed on Main") {
flakySafely { onMainScreen { tokenWithTitleAndAddress(tokenTitle).assertIsDisplayed() } }
}
}
}
@AllureId("771")
@DisplayName("Add custom token: custom derivation indicator is shown on Main")
@Test
fun customDerivationIndicatorOnMainTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") {
flakySafely { onMainScreen { synchronizeAddressesButton.assertIsDisplayed() } }
synchronizeAddresses(assertBalance = false)
}
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Add custom token with custom derivation on '$ethereumNetwork'") {
addCustomTokenWithCustomDerivation(
network = ethereumNetwork,
contract = ethContract,
derivationPath = customDerivationPath,
)
}
step("Assert custom derivation indicator for '$tokenTitle' is displayed on Main") {
flakySafely { onMainScreen { tokenWithCustomDerivationIcon(tokenTitle).assertIsDisplayed() } }
}
}
}
@AllureId("770")
@DisplayName("Add custom token: derivation field is available for a non-EVM network")
@Test
fun derivationAvailableForNonEvmNetworkTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Click on network: '$solanaNetwork'") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(solanaNetwork) } }
onAddCustomTokenScreen { networkRow(solanaNetwork).performClick() }
}
step("Assert 'Derivation path' field is available") {
flakySafely { onAddCustomTokenScreen { derivationSelectorField.assertExists() } }
}
}
}
@AllureId("769")
@DisplayName("Add custom token: Solana token on a modern card shows no unsupported warning")
@Test
fun solanaTokenModernCardNoWarningTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Click on network: '$solanaNetwork'") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(solanaNetwork) } }
onAddCustomTokenScreen { networkRow(solanaNetwork).performClick() }
}
step("Enter contract address: '$solanaContract'") {
flakySafely { onAddCustomTokenScreen { contractAddressField.assertExists() } }
onAddCustomTokenScreen { contractAddressField.performTextInputInChunks(solanaContract) }
}
step("Assert 'Add token' button is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onAddCustomTokenScreen { addTokenButton.assertIsEnabled() } }
}
step("Assert unsupported-token warning is not displayed") {
onAddCustomTokenScreen { warningNotification.assertDoesNotExist() }
}
}
}
@AllureId("777")
@DisplayName("Add custom token: network with a missing curve is not offered")
@Test
fun missingCurveNetworkNotOfferedTest() {
val bitcoinNetwork = "Bitcoin"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Wallet2NoEd25519Slip0010MockContent) }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Assert supported network '$bitcoinNetwork' is displayed") {
flakySafely { onAddCustomTokenScreen { scrollToNetwork(bitcoinNetwork) } }
}
step("Assert '$solanaNetwork' network is not displayed for the missing-curve card") {
onAddCustomTokenScreen { selectorList.assertIsDisplayed() }
val solanaOffered = runCatching { onAddCustomTokenScreen { scrollToNetwork(solanaNetwork) } }.isSuccess
assertFalse("'$solanaNetwork' should not be offered for a missing-curve card", solanaOffered)
}
}
}
@AllureId("776")
@DisplayName("Add custom token: derivation paths match a legacy-batch V1 Wallet card")
@Test
fun derivationPathsLegacyBatchWalletTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Wallet1LegacyDerivationMockContent) }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Assert V1 derivation paths are displayed in the selector") {
assertDerivationPathsInSelector(
ethereumNetwork,
bitcoinNetworkId to "m/44'/0'/0'/0/0",
ethereumClassicNetworkId to "m/44'/61'/0'/0/0",
)
}
}
}
@AllureId("10204")
@DisplayName("Add custom token: derivation paths match a V2 Wallet card")
@Test
fun derivationPathsWalletTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Assert V2 derivation paths are displayed in the selector") {
assertDerivationPathsInSelector(
ethereumNetwork,
bitcoinNetworkId to "m/44'/0'/0'/0/0",
ethereumClassicNetworkId to "m/44'/60'/0'/0/0",
)
}
}
}
@AllureId("10205")
@DisplayName("Add custom token: derivation paths match a V3 Wallet 2 card")
@Test
fun derivationPathsWallet2Test() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(productType = ProductType.Wallet2) }
step("Open 'Add custom token' screen") { openAddCustomToken() }
step("Assert V3 derivation paths are displayed in the selector") {
assertDerivationPathsInSelector(
ethereumNetwork,
bitcoinNetworkId to "m/84'/0'/0'/0/0",
ethereumClassicNetworkId to "m/44'/61'/0'/0/0",
)
}
}
}
}

View file

@ -0,0 +1,250 @@
package com.tangem.tests.main
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.performTextInput
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.COINS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.assertClipboardIsEmpty
import com.tangem.common.utils.assertClipboardTextEquals
import com.tangem.common.utils.clearClipboard
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openManageTokens
import com.tangem.scenarios.toggleTokenNetworkInManageTokens
import com.tangem.screens.onDialog
import com.tangem.screens.onManageTokensScreen
import com.tangem.tap.domain.sdk.mocks.content.Firmware451MockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2NoEd25519Slip0010MockContent
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.res.R as CoreResR
@HiltAndroidTest
class ManageTokensTest : BaseTestCase() {
private val richState = "ManageTokensRich"
private val solanaTokenTitle = "USD Coin"
private val solanaNetworkTitle = "SOLANA"
private val solanaName = "Solana"
@AllureId("765")
@DisplayName("Manage tokens: network standard labels are shown for token networks")
@Test
fun networkStandardLabelsTest() {
val tokenTitle = "Tether"
val ethereum = "Ethereum"
val bnbSmartChain = "BNB Smart Chain"
val tron = "Tron"
val erc20 = "ERC20"
val bep20 = "BEP20"
val trc20 = "TRC20"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Click on token: '$tokenTitle'") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Assert '$ethereum' network standard '$erc20' is displayed") {
flakySafely {
onManageTokensScreen { networkStandard(networkName = ethereum, standard = erc20).assertIsDisplayed() }
}
}
step("Assert '$bnbSmartChain' network standard '$bep20' is displayed") {
flakySafely {
onManageTokensScreen {
networkStandard(networkName = bnbSmartChain, standard = bep20).assertIsDisplayed()
}
}
}
step("Assert '$tron' network standard '$trc20' is displayed") {
flakySafely {
onManageTokensScreen { networkStandard(networkName = tron, standard = trc20).assertIsDisplayed() }
}
}
}
}
@AllureId("667")
@DisplayName("Manage tokens: search by name and ticker filters the list")
@Test
fun searchByNameAndTickerTest() {
val tokenTitle = "Tether"
val nameQuery = "Tether"
val tickerQuery = "USDT"
val emptyQuery = "Zzqnotoken"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Search by name: '$nameQuery'") {
onManageTokensScreen {
searchField.performClick()
searchField.performTextInput(nameQuery)
}
}
step("Assert token: '$tokenTitle' is displayed") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
}
step("Clear search field") {
onManageTokensScreen { searchClearButton.clickWithAssertion() }
}
step("Search by ticker: '$tickerQuery'") {
onManageTokensScreen { searchField.performTextInput(tickerQuery) }
}
step("Assert token: '$tokenTitle' is displayed") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
}
step("Clear search field") {
onManageTokensScreen { searchClearButton.clickWithAssertion() }
}
step("Search by unknown query: '$emptyQuery'") {
onManageTokensScreen { searchField.performTextInput(emptyQuery) }
}
step("Assert token: '$tokenTitle' is not displayed") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertDoesNotExist() } }
}
}
}
@AllureId("763")
@DisplayName("Manage tokens: enabling Solana network on a modern card shows no warning")
@Test
fun solanaNetworkNoWarningTest() {
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Enable '$solanaNetworkTitle' network for token: '$solanaTokenTitle'") {
toggleTokenNetworkInManageTokens(tokenTitle = solanaTokenTitle, networkTitle = solanaNetworkTitle)
}
step("Assert hide-token alert is not displayed") {
waitForIdle()
onDialog { dialogContainer.assertDoesNotExist() }
}
}
}
@AllureId("719")
@DisplayName("Manage tokens: long tap on a token network copies the contract address, main network does not")
@Test
fun copyContractAddressOnNetworkLongTapTest() {
val tokenTitle = "Tether"
val tokenNetworkTitle = "ETHEREUM"
val coinTitle = "Bitcoin"
val coinNetworkTitle = "BITCOIN"
val contractAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Click on token: '$tokenTitle'") {
flakySafely { onManageTokensScreen { tokenItem(tokenTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Long click on '$tokenNetworkTitle' network row") {
flakySafely { onManageTokensScreen { networkName(tokenNetworkTitle).assertIsDisplayed() } }
onManageTokensScreen {
networkName(tokenNetworkTitle).performTouchInput {
longClick(position = center, durationMillis = 1000L)
}
}
}
step("Assert contract-address-copied message is displayed") {
flakySafely { onManageTokensScreen { contractAddressCopiedMessage.assertIsDisplayed() } }
}
step("Assert clipboard contains '$tokenTitle' contract address") {
assertClipboardTextEquals(contractAddress)
}
step("Clear clipboard") { clearClipboard() }
step("Click on token: '$coinTitle'") {
flakySafely { onManageTokensScreen { tokenItem(coinTitle).assertIsDisplayed() } }
onManageTokensScreen { tokenItem(coinTitle).performClick() }
}
step("Long click on '$coinNetworkTitle' main network row") {
flakySafely { onManageTokensScreen { networkName(coinNetworkTitle).assertIsDisplayed() } }
onManageTokensScreen {
networkName(coinNetworkTitle).performTouchInput {
longClick(position = center, durationMillis = 1000L)
}
}
}
step("Assert clipboard is empty after long click on main network") {
waitForIdle()
assertClipboardIsEmpty()
}
}
}
@AllureId("737")
@DisplayName("Manage tokens: enabling Solana on an old-firmware card shows firmware-limitation warning")
@Test
fun solanaFirmwareLimitationWarningTest() {
val warningMessage = getResourceString(CoreResR.string.alert_manage_tokens_unsupported_message, solanaName)
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Firmware451MockContent) }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Enable '$solanaNetworkTitle' network for token: '$solanaTokenTitle'") {
toggleTokenNetworkInManageTokens(tokenTitle = solanaTokenTitle, networkTitle = solanaNetworkTitle)
}
step("Assert firmware-limitation warning is displayed") {
flakySafely { onDialog { containerWithText(warningMessage).assertIsDisplayed() } }
}
}
}
@AllureId("767")
@DisplayName("Manage tokens: enabling Solana on a card missing its curve shows unsupported-curve warning")
@Test
fun solanaUnsupportedCurveWarningTest() {
val warningMessage =
getResourceString(CoreResR.string.alert_manage_tokens_unsupported_curve_message, solanaName)
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(COINS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$COINS_API_SCENARIO' to state: '$richState'") {
setWireMockScenarioState(COINS_API_SCENARIO, richState)
}
step("Open 'Main Screen'") { openMainScreen(mockContent = Wallet2NoEd25519Slip0010MockContent) }
step("Open 'Manage tokens' screen") { openManageTokens() }
step("Enable '$solanaNetworkTitle' network for token: '$solanaTokenTitle'") {
toggleTokenNetworkInManageTokens(tokenTitle = solanaTokenTitle, networkTitle = solanaNetworkTitle)
}
step("Assert unsupported-curve warning is displayed") {
flakySafely { onDialog { containerWithText(warningMessage).assertIsDisplayed() } }
}
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.addNewCardWalletWithoutSync
import com.tangem.scenarios.assertOrganizeTokensMatch
import com.tangem.scenarios.getMainScreenTokensOrder
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.switchToPreviousWallet
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onOrganizeTokensScreen
import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertEquals
import org.junit.Test
@HiltAndroidTest
class OrganizeTokensTest : BaseTestCase() {
@AllureId("71")
@DisplayName("Organize tokens: Correct tokens list displaying for current wallet")
@Test
fun organizeTokensCorrectTokensListDisplaying() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Add a second card wallet") {
addNewCardWalletWithoutSync(Wallet2MockContent)
}
step("Switch to first wallet Main screen") {
switchToPreviousWallet()
}
val firstWalletTokens = getMainScreenTokensOrder()
assertOrganizeTokensMatch(firstWalletTokens)
step("Switch to second wallet Main screen") {
onMainScreen { swipeToAdjacentWallet(toPrevious = false) }
}
val secondWalletTokens = getMainScreenTokensOrder()
assertOrganizeTokensMatch(secondWalletTokens)
}
}
@AllureId("2753")
@DisplayName("Organize tokens: Tokens order changing")
@Test
fun organizeTokensOrderChanging() {
setupHooks().run{
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Organize tokens' bottom-sheet") {
onMainScreen { clickDisplayedAddAndManageButton() }
onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() }
}
step("Drag the 3rd token onto the 2nd position and assert the new order") {
onOrganizeTokensScreen {
val sourceIndex = 2
val destinationIndex = 1
val before = getDisplayedTokenTitles()
require(before.size > sourceIndex && before.size > destinationIndex) {
"Expected at least ${maxOf(sourceIndex, destinationIndex) + 1} tokens to reorder, but got ${before.size}: $before"
}
dragToken(source = before[sourceIndex], destination = before[destinationIndex])
val expected = before.toMutableList().apply {
add(destinationIndex, removeAt(sourceIndex))
}
assertEquals(expected, getDisplayedTokenTitles())
}
}
}
}
}

View file

@ -4,12 +4,17 @@ import com.tangem.common.BaseTestCase
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.addNewCardWalletWithoutSync
import com.tangem.scenarios.getMainScreenTokensOrder
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.Wallet2MockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@HiltAndroidTest
@ -48,4 +53,54 @@ class TokenListTest : BaseTestCase() {
}
}
@AllureId("177")
@DisplayName("Main: token list differs after switching to a second wallet")
@Test
fun tokenListChangedAfterSwitchingWalletTest() {
val userTokensState = "ReducedTokens"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
val firstWalletTokens = getMainScreenTokensOrder()
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, userTokensState)
}
step("Add a second card wallet") {
addNewCardWalletWithoutSync(Wallet2MockContent)
}
val secondWalletTokens = getMainScreenTokensOrder()
step("Assert both wallets exposed a non-empty token list") {
assertTrue(
"First wallet token list should not be empty",
firstWalletTokens.isNotEmpty(),
)
assertTrue(
"Second wallet token list should not be empty",
secondWalletTokens.isNotEmpty(),
)
}
step("Assert the two wallets show different token lists") {
assertNotEquals(
"Expected the second wallet's token list to differ from the first " +
"(first=$firstWalletTokens, second=$secondWalletTokens)",
firstWalletTokens,
secondWalletTokens,
)
}
}
}
}

View file

@ -27,11 +27,9 @@ class WarningsTest : BaseTestCase() {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}

View file

@ -0,0 +1,178 @@
package com.tangem.tests.referral
import androidx.test.core.app.ApplicationProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.tapBackButton
import com.tangem.common.utils.AddressComparisonHelper
import com.tangem.common.utils.getClipboardText
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openTesterMenu
import com.tangem.scenarios.referralTakeParticipate
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onTesterMenuScreen
import com.tangem.screens.onWalletSettingsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertEquals
import org.junit.Test
@HiltAndroidTest
class ReferralTest : BaseTestCase() {
@AllureId("3630")
@DisplayName("Referral program: Token and Blockchain added on Main screen after participation")
@Test
fun referralTokenAndBlockchainAddedAfterParticipationTest() {
val tokenNetwork = "Tron"
val token = "Tether"
setupHooks(
additionalAfterSection = {
resetWireMockScenarios()
}
).run {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Verify network $tokenNetwork and token $token is not displayed") {
onMainScreen {
assertTokenDoesNotExist(tokenNetwork)
assertTokenDoesNotExist(token)
}
}
step("Take participate in Referral program") {
referralTakeParticipate()
}
step("Return to the 'Main' screen") {
tapBackButton()
onWalletSettingsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onDetailsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Verify network $tokenNetwork and token $token is displayed on 'Main' screen") {
onMainScreen {
tokenWithTitleAndAddress(tokenNetwork)
tokenWithTitleAndAddress(token)
}
}
}
}
@AllureId("10098")
@DisplayName("Referral program: Token added on Main screen after participation")
@Test
fun referralTokenAddedAfterParticipationTest() {
val userWalletScenarioName = "user_tokens_api"
val userWalletState = "Tron"
val tokenNetwork = "Tron"
val token = "Tether"
setupHooks(
additionalAfterSection = {
resetWireMockScenarios()
}
).run {
step("Set wiremock scenario: $userWalletScenarioName to state $userWalletState") {
setWireMockScenarioState(scenarioName = userWalletScenarioName, state = userWalletState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Verify token $token is not displayed") {
onMainScreen { assertTokenDoesNotExist(token) }
}
step("Verify network $tokenNetwork is displayed") {
onMainScreen { tokenWithTitleAndAddress(tokenNetwork) }
}
step("Take participate in Referral program") {
referralTakeParticipate()
}
step("Return to the 'Main' screen") {
tapBackButton()
onWalletSettingsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onDetailsScreen { screenContainer.assertIsDisplayed() }
tapBackButton()
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Verify $token is displayed on 'Main' screen") {
onMainScreen { tokenWithTitleAndAddress(token) }
}
}
}
@AllureId("3629")
@DisplayName("Referral program: Participating unavailable for No-Wallet cards")
@Test
fun referralUnavailableForNoWalletCardsTest() {
setupHooks().run {
step("Open 'Main' screen") {
openMainScreen(productType = ProductType.Note)
}
step("Open 'Details' screen") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }
}
step("Verify 'Referral program' button is not displayed") {
onWalletSettingsScreen { referralProgramButton.assertDoesNotExist() }
}
}
}
@AllureId("3636")
@DisplayName("Referral program: Verify wallet derivation")
@Test
fun referralVerifyWalletDerivationTest() {
val tokenNetwork = "Tron"
val expectedDerivationPath = "m/44'/195'/0'/0/0"
setupHooks(
additionalAfterSection = {
resetWireMockScenarios()
}
).run {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Take participate in Referral program") {
referralTakeParticipate()
}
step("Open Debug menu") {
openTesterMenu()
}
step("Open 'Addresses info' screen") {
onTesterMenuScreen { addressesInfoButton.clickWithAssertion() }
}
step("Verify $tokenNetwork derivation path is '$expectedDerivationPath'") {
onTesterMenuScreen { jsonTab.clickWithAssertion() }
onTesterMenuScreen { copyButton.clickWithAssertion() }
val addressesJson = getClipboardText(ApplicationProvider.getApplicationContext())
?: error("Clipboard is empty after copying addresses")
val actualDerivationPath =
AddressComparisonHelper.derivationPathForBlockchain(addressesJson, tokenNetwork)
assertEquals(expectedDerivationPath, actualDerivationPath)
}
}
}
}

View file

@ -24,6 +24,7 @@ import org.junit.Test
@HiltAndroidTest
class SendConfirmScreenTest : BaseTestCase() {
@Ignore("[REDACTED_JIRA]")
@AllureId("4003")
@DisplayName("Send (Confirm screen): change sending amount")
@Test

View file

@ -132,9 +132,9 @@ class GaslessSendTest : BaseTestCase() {
@DisplayName("Gasless: completed gasless transaction is shown in token transaction history")
@Test
fun checkGaslessTransactionInHistoryTest() {
val sentAmount = "1.00"
val operationAmount = "1.00"
val gaslessFeeAmount = "0.10"
val sentTitle = getResourceString(R.string.common_sent)
val operationTitle = getResourceString(R.string.transaction_history_operation)
val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee)
setupHooks(
@ -166,13 +166,13 @@ class GaslessSendTest : BaseTestCase() {
onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() }
}
}
step("Assert '$sentTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() }
step("Assert '$operationTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(operationTitle).assertIsDisplayed() }
}
step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") {
step("Assert '$operationTitle' amount '$operationAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true)
transactionCurrency(sentTitle).assertTextEquals(currencySymbol)
transactionAmount(operationTitle).assertTextContains(operationAmount, substring = true)
transactionCurrency(operationTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") {

View file

@ -0,0 +1,123 @@
package com.tangem.tests.send.reasonBlock
import com.tangem.common.BaseTestCase
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.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
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 ReasonBlockTest : BaseTestCase() {
@AllureId("3616")
@DisplayName("Reason block: Send is unavailable if user has pending transaction")
@Test
fun reasonBlockSendUnavailableWithPendingTransactionTest() {
val txHistoryScenarioName = "dogecoin_tx_history"
val txHistoryState = "EmptyWithPendingTransaction"
val walletsScenarioName = "user_tokens_api"
val walletsState = "Dogecoin"
val token = "Dogecoin"
val reasonText = getResourceString(R.string.token_button_unavailability_reason_pending_transaction_send)
.substringBefore("%s")
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(txHistoryScenarioName)
resetWireMockScenarioState(walletsScenarioName)
}
).run {
step("Set Wiremock scenario: $txHistoryScenarioName to state $txHistoryState") {
setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryState)
}
step("Set Wiremock scenario: $walletsScenarioName to state $walletsState") {
setWireMockScenarioState(scenarioName = walletsScenarioName, state = walletsState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name $token") {
onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Verify 'Send' button is disabled") {
onTransferBottomSheet { sendButton.assertIsNotEnabled() }
}
step("Click on 'Send' button") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Assert pending-transaction reason dialog is displayed") {
onDialog { containerWithText(reasonText).assertIsDisplayed() }
}
}
}
@AllureId("3615")
@DisplayName("Reason block: Token withdrawal is unavailable if there are no fee coverage")
@Test
fun reasonBlockTokenWithdrawalUnavailableWithoutFeeCoverage() {
val userWalletsScenarioName = "user_tokens_api"
val userWalletsState = "SolanaUSDC"
val solBalanceScenarioName = "solana_get_account_info_recipient"
val solBalanceState = "ZeroBalance"
val token = "USDC"
val feeCurrencyName = "Solana"
val feeCurrencySymbol = "SOL"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(userWalletsScenarioName)
resetWireMockScenarioState(solBalanceScenarioName)
}
).run {
step("Set Wiremock scenario: $userWalletsScenarioName to state $userWalletsState") {
setWireMockScenarioState(scenarioName = userWalletsScenarioName, state = userWalletsState)
}
step("Set Wiremock scenario: $solBalanceScenarioName to state $solBalanceState") {
setWireMockScenarioState(scenarioName = solBalanceScenarioName, state = solBalanceState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name $token") {
onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() }
}
step("Assert 'Insufficient $feeCurrencySymbol for fee' notification is displayed") {
onTokenDetailsScreen {
networkFeeNotificationTitle(feeCurrencyName).assertIsDisplayed()
networkFeeNotificationMessage(
currencyName = token,
networkName = feeCurrencyName,
feeCurrencyName = feeCurrencyName,
feeCurrencySymbol = feeCurrencySymbol,
).assertIsDisplayed()
}
}
step("Click on 'Go to $feeCurrencySymbol' button") {
onTokenDetailsScreen { goToBuyCurrencyButton(feeCurrencySymbol).clickWithAssertion() }
}
step("Assert $feeCurrencyName token screen is opened") {
onTokenDetailsScreen { tokenTitle(feeCurrencyName).assertIsDisplayed() }
}
}
}
}

View file

@ -155,7 +155,11 @@ class GaslessSendViaSwapTest : BaseTestCase() {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
selectStablecoinAsFeeToken(
coinName = nativeTokenName,
tokenName = tokenName,
expectApplyEnabled = false,
)
}
step("Assert 'Not enough funds' error is displayed in the fee selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {

View file

@ -14,6 +14,7 @@ import com.tangem.common.extensions.extractText
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
@ -825,4 +826,112 @@ class SendViaSwapTest : BaseTestCase() {
}
}
}
@AllureId("3972")
@DisplayName("Send via Swap: single-currency card has no 'Convert' button in Send")
@Test
fun singleCurrencyCardNoConvertButtonInSendTest() {
val cardType = ProductType.Note
val tokenTitle = "Dogecoin"
setupHooks().run {
step("Open 'Main Screen' on '${cardType.name}' card") {
openMainScreen(cardType)
}
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Transfer' button is displayed") {
onMainScreen { transferButton.assertIsDisplayed() }
}
step("Assert 'Swap' button is not displayed") {
onMainScreen { swapButton.assertIsNotDisplayed() }
}
step("Click on 'Transfer' button") {
onMainScreen { transferButton.performClick() }
}
step("Click on token: '$tokenTitle'") {
onChooseTokenBottomSheet { tokenWithTitle(tokenTitle).clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Assert amount input text field is displayed") {
flakySafely { onSendScreen { amountInputTextField.assertIsDisplayed() } }
}
step("Assert 'Swap to another token' button is not displayed") {
onSendScreen { swapToAnotherTokenButton.assertDoesNotExist() }
}
}
}
@AllureId("10196")
@DisplayName("Send via Swap: memo-restricted network is not selectable as a receive token")
@Test
fun memoRestrictedNetworkNotSelectableAsReceiveTokenTest() {
val tokenName = "Bitcoin"
val algorand = "Algorand"
val bitcoinBalanceScenarioName = "bitcoin_utxo"
val bitcoinBalanceScenarioState = "Balance"
val assetsScenarioName = "express_api_assets"
val assetsScenarioState = "BitcoinExchangeEnabled"
val userTokensScenarioState = "Wallet2"
val coinsScenarioName = "coins_api"
val coinsScenarioState = "WithAlgorand"
val warningTitle = getResourceString(R.string.express_swap_not_supported_title, algorand)
val warningMessage = getResourceString(R.string.express_swap_not_supported_text)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(bitcoinBalanceScenarioName)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(coinsScenarioName)
}
).run {
step("Set WireMock scenario: '$bitcoinBalanceScenarioName' to state: '$bitcoinBalanceScenarioState'") {
setWireMockScenarioState(scenarioName = bitcoinBalanceScenarioName, state = bitcoinBalanceScenarioState)
}
step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState)
}
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState)
}
step("Set WireMock scenario: '$coinsScenarioName' to state: '$coinsScenarioState'") {
setWireMockScenarioState(scenarioName = coinsScenarioName, state = coinsScenarioState)
}
step("Open 'Send' screen") {
openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent)
}
step("Click on 'Swap to another token' button") {
onSendScreen { swapToAnotherTokenButton.performClick() }
}
step("Type '$algorand' in search text field") {
onSendViaSwapScreen {
searchField.performClick()
searchField.performTextInput(algorand)
}
}
step("Click on token: '$algorand'") {
onSendViaSwapScreen { tokenItem(algorand).performClick() }
}
step("Assert warning bottom sheet icon is displayed") {
onWarningBottomSheet { icon.assertIsDisplayed() }
}
step("Assert warning bottom sheet title is displayed") {
onWarningBottomSheet { title(warningTitle).assertIsDisplayed() }
}
step("Assert warning bottom sheet message is displayed") {
onWarningBottomSheet { message(warningMessage).assertIsDisplayed() }
}
step("Click on 'Ok, Got it!' button") {
onWarningBottomSheet { okGotItButton.performClick() }
}
step("Assert 'Swap to another token' screen is displayed") {
onSendViaSwapScreen { tokenItem(algorand).assertIsDisplayed() }
}
}
}
}

View file

@ -4,9 +4,11 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS
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.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen
@ -20,7 +22,7 @@ import org.junit.Test
@HiltAndroidTest
class SolanaWarningsTest : BaseTestCase() {
private val tokenName = "Solana"
private val amountToLeaveLessThanRent = "0.0016941"
private val amountToLeaveLessThanRent = "0.0372"
private val amountToLeaveGreaterThanRent = "0.0000941"
private val amountToLeaveRentOnly = "0.001689338"
private val rentAmount = "SOL 0.00089088"
@ -90,8 +92,10 @@ class SolanaWarningsTest : BaseTestCase() {
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(SOLANA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
step("Open 'Send confirm' screen via 'Next' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaNextButton()
}
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(

View file

@ -58,8 +58,8 @@ class SearchAndSwapTest : BaseTestCase() {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
step("Click on 'Confirm' button") {
onAddTokenBottomSheet { confirmButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
@ -106,8 +106,8 @@ class SearchAndSwapTest : BaseTestCase() {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
step("Click on 'Confirm' button") {
onAddTokenBottomSheet { confirmButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }
@ -195,8 +195,8 @@ class SearchAndSwapTest : BaseTestCase() {
onSwapSelectTokenScreen { marketsTokenWithName(receiveTokenName).clickWithAssertion() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
step("Click on 'Confirm' button") {
onAddTokenBottomSheet { confirmButton.performClick() }
}
step("Assert warning title '$warningTitle' is displayed") {
onSwapTokenScreen { warningTitle(warningTitle).assertIsDisplayed() }
@ -251,8 +251,8 @@ class SearchAndSwapTest : BaseTestCase() {
onSwapSelectTokenScreen { marketsTokenWithName(swapTokenName).performClick() }
}
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.performClick() }
step("Click on 'Confirm' button") {
onAddTokenBottomSheet { confirmButton.performClick() }
}
step("Assert swap token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() }

View file

@ -84,8 +84,8 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
val tokenTitle = "USDC"
val inputAmount = "1000"
val tokensScenarioState = "SolanaUSDC"
val balanceScenarioName = "solana_balance"
val balanceScenarioState = "Empty"
val balanceScenarioName = "solana_get_account_info_recipient"
val balanceScenarioState = "ZeroBalance"
val pairsScenarioName = "solana_from_pairs"
val pairsScenarioState = "DexProvider"
val networkName = "Solana"
@ -554,7 +554,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
fun solanaRemainingBalanceLessThanRentAmountTest() {
val tokenTitle = "Solana"
val receiveTokenName = "USDC"
val inputAmount = "0.0016941"
val inputAmount = "0.0372"
val tokensScenarioState = "SolanaUSDC"
val rentAmount = "SOL 0.00089088"
val notificationTitle = getResourceString(R.string.send_notification_invalid_amount_title)

View file

@ -0,0 +1,204 @@
package com.tangem.tests.tangempay
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.res.R as CoreResR
import com.tangem.scenarios.openTangemPayDailyLimitSetup
import com.tangem.screens.onDialog
import com.tangem.screens.tangempay.onTangemPayCardPageScreen
import com.tangem.screens.tangempay.onTangemPayDailyLimitScreen
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 TangemPayDailyLimitTest : BaseTestCase() {
private val dailyLimitScenario = "tangem_pay_daily_limit"
private val highLimitState = "HighLimit"
private val setErrorState = "SetError"
private val eligibilityState = "PaeraCustomer"
@AllureId("9726")
@DisplayName("Tangem Pay: daily limit screen is displayed correctly")
@Test
fun dailyLimitScreenIsDisplayedCorrectlyTest() {
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(dailyLimitScenario, highLimitState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(dailyLimitScenario)
},
).run {
step("Open daily limit setup screen") { openTangemPayDailyLimitSetup() }
step("Assert amount field is displayed") {
onTangemPayDailyLimitScreen { amountField.assertIsDisplayed() }
}
step("Assert hint is displayed") {
onTangemPayDailyLimitScreen { hint.assertIsDisplayed() }
}
step("Assert 'Set limits' button is displayed") {
onTangemPayDailyLimitScreen { setLimitsButton.assertIsDisplayed() }
}
step("Assert quick value preset '1' is displayed") {
onTangemPayDailyLimitScreen { presetChip("1").assertIsDisplayed() }
}
step("Assert quick value preset '5000' is displayed") {
onTangemPayDailyLimitScreen { presetChip("5000").assertIsDisplayed() }
}
step("Assert quick value preset '10000' is displayed") {
onTangemPayDailyLimitScreen { presetChip("10000").assertIsDisplayed() }
}
step("Assert quick value preset '25000' is displayed") {
onTangemPayDailyLimitScreen { presetChip("25000").assertIsDisplayed() }
}
}
}
@AllureId("9724")
@DisplayName("Tangem Pay: selected quick value is applied to the amount field")
@Test
fun selectedQuickValueIsAppliedToAmountFieldTest() {
val presetValue = "5000"
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(dailyLimitScenario, highLimitState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(dailyLimitScenario)
},
).run {
step("Open daily limit setup screen") { openTangemPayDailyLimitSetup() }
step("Click on '$presetValue' quick value preset") {
onTangemPayDailyLimitScreen { presetChip(presetValue).clickWithAssertion() }
}
step("Assert amount field contains '$presetValue'") {
onTangemPayDailyLimitScreen { amountField.assertTextContainsSafe(presetValue) }
}
}
}
@AllureId("9725")
@DisplayName("Tangem Pay: 'Set limits' is disabled when the amount is above the limit")
@Test
fun setLimitsIsDisabledWhenAmountAboveLimitTest() {
val amountAboveLimit = "300000"
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(dailyLimitScenario, highLimitState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(dailyLimitScenario)
},
).run {
step("Open daily limit setup screen") { openTangemPayDailyLimitSetup() }
step("Enter amount '$amountAboveLimit' above the limit") {
onTangemPayDailyLimitScreen { amountField.performTextReplacement(amountAboveLimit) }
}
step("Assert 'Set limits' button is not enabled") {
onTangemPayDailyLimitScreen { setLimitsButton.assertIsNotEnabled() }
}
}
}
@AllureId("9739")
@DisplayName("Tangem Pay: successful daily limit setup")
@Test
fun successfulDailyLimitSetupTest() {
val newLimit = "5000"
val displayedLimit = "5,000"
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(dailyLimitScenario, highLimitState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(dailyLimitScenario)
},
).run {
step("Open daily limit setup screen") { openTangemPayDailyLimitSetup() }
step("Enter amount '$newLimit'") {
onTangemPayDailyLimitScreen { amountField.performTextReplacement(newLimit) }
}
step("Click on 'Set limits' button") {
onTangemPayDailyLimitScreen { setLimitsButton.clickWithAssertion() }
}
step("Assert daily limit success screen is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTangemPayDailyLimitScreen { successTitle.assertIsDisplayed() }
}
}
step("Click on 'Done' button") {
onTangemPayDailyLimitScreen { doneButton.clickWithAssertion() }
}
step("Assert daily limit value contains '$displayedLimit'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTangemPayCardPageScreen {
dailyLimitValue.assertTextContainsSafe(displayedLimit, substring = true)
}
}
}
}
}
@AllureId("9727")
@DisplayName("Tangem Pay: error is shown when the limit change fails")
@Test
fun errorIsShownWhenLimitChangeFailsTest() {
val newLimit = "5000"
val errorTitle = getResourceString(CoreResR.string.common_something_went_wrong)
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(dailyLimitScenario, setErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(dailyLimitScenario)
},
).run {
step("Open daily limit setup screen") { openTangemPayDailyLimitSetup() }
step("Enter amount '$newLimit'") {
onTangemPayDailyLimitScreen { amountField.performTextReplacement(newLimit) }
}
step("Click on 'Set limits' button") {
onTangemPayDailyLimitScreen { setLimitsButton.clickWithAssertion() }
}
step("Assert limit change error dialog is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onDialog {
title.assertTextContainsSafe(errorTitle)
}
}
}
step("Click on 'OK' button") {
onDialog { okButton.clickWithAssertion() }
}
}
}
}

View file

@ -0,0 +1,220 @@
package com.tangem.tests.tangempay
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ACCESS_CODE
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
import com.tangem.common.constants.TestConstants.TANGEM_PAY_KYC_STATUS_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_SHORT
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreenWithExistingHotWallet
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.tangempay.onTangemPayKycSheet
import com.tangem.screens.tangempay.onTangemPayMainScreen
import com.tangem.screens.tangempay.onTangemPayOnboardingScreen
import com.tangem.core.res.R as CoreResR
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 TangemPayOnboardingKycTest : BaseTestCase() {
@AllureId("9630")
@DisplayName("Tangem Pay: existing customer authorization shows the tile on Main")
@Test
fun existingUserAuthorizationShowsTangemPayTileOnMainTest() {
val paeraCustomerState = "PaeraCustomer"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
},
).run {
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to '$paeraCustomerState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, paeraCustomerState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
}
step("Assert Tangem Pay tile is displayed on Main") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTangemPayMainScreen { mainScreenTile.assertIsDisplayed() }
}
}
}
}
@AllureId("9632")
@DisplayName("Tangem Pay: Get Tangem Pay banner is shown on Main for the BANNER channel")
@Test
fun getTangemPayBannerShownOnMainWhenBannerChannelReceivedTest() {
val eligibilityStartedState = "Started"
val channelsBannerState = "Banner"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO)
},
).run {
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to '$eligibilityStartedState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityStartedState)
}
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO' to '$channelsBannerState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO, channelsBannerState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
}
step("Assert 'Get Tangem Pay' banner is displayed on Main") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { getTangemPayBanner.assertIsDisplayed() }
}
}
}
}
@AllureId("9673")
@DisplayName("Tangem Pay: onboarding opens from app Details for the DETAILS channel")
@Test
fun onboardingOpensFromAppDetailsWhenDetailsChannelReceivedTest() {
val eligibilityStartedState = "Started"
val channelsBannerAndDetailsState = "BannerAndDetails"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO)
},
).run {
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to '$eligibilityStartedState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityStartedState)
}
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO' to '$channelsBannerAndDetailsState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_CHANNELS_SCENARIO, channelsBannerAndDetailsState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
}
step("Click on 'More' button") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Assert 'Get Tangem Pay' row is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onDetailsScreen { getTangemPayRow.assertIsDisplayed() }
}
}
step("Click on 'Get Tangem Pay' row") {
onDetailsScreen { getTangemPayRow.clickWithAssertion() }
}
step("Assert Tangem Pay onboarding screen is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTangemPayOnboardingScreen { title.assertIsDisplayed() }
}
}
}
}
@AllureId("9643")
@DisplayName("Tangem Pay: KYC in progress sheet closes and the status remains on Main")
@Test
fun kycInProgressSheetClosesAndStatusRemainsOnMainTest() {
val paeraCustomerState = "PaeraCustomer"
val kycInProgressState = "InProgress"
val kycInProgressText = getResourceString(CoreResR.string.tangempay_kyc_in_progress)
val viewStatusText = getResourceString(CoreResR.string.tangempay_kyc_in_progress_notification_button)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO)
},
).run {
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to '$paeraCustomerState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, paeraCustomerState)
}
step("Set WireMock scenario '$TANGEM_PAY_KYC_STATUS_SCENARIO' to '$kycInProgressState'") {
setWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO, kycInProgressState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
}
step("Assert Tangem Pay tile with '$kycInProgressText' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTangemPayMainScreen { tileWithSubtitle(kycInProgressText).assertIsDisplayed() }
}
}
step("Click on Tangem Pay tile") {
onTangemPayMainScreen { mainScreenTile.clickWithAssertion() }
}
step("Assert KYC in progress sheet is displayed") {
onTangemPayKycSheet {
title(kycInProgressText).assertIsDisplayed()
primaryButtonWithText(viewStatusText).assertIsDisplayed()
}
}
step("Click on 'Close' button") {
onTangemPayKycSheet { closeButton.clickWithAssertion() }
}
step("Assert KYC in progress sheet is not displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_SHORT) {
onTangemPayKycSheet { primaryButtonWithText(viewStatusText).assertDoesNotExist() }
}
}
step("Assert Tangem Pay tile with '$kycInProgressText' is still displayed") {
onTangemPayMainScreen { tileWithSubtitle(kycInProgressText).assertIsDisplayed() }
}
}
}
@AllureId("9517")
@DisplayName("Tangem Pay: KYC rejected status shown on Main and in the sheet")
@Test
fun kycRejectedStatusShownOnMainAndInSheetTest() {
val paeraCustomerState = "PaeraCustomer"
val kycDeclinedState = "Declined"
val kycRejectedTileText = getResourceString(CoreResR.string.tangempay_kyc_has_failed)
val rejectedSheetTitle = getResourceString(CoreResR.string.tangempay_kyc_rejected)
val goToSupportText = getResourceString(CoreResR.string.tangempay_go_to_support)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO)
},
).run {
step("Set WireMock scenario '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to '$paeraCustomerState'") {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, paeraCustomerState)
}
step("Set WireMock scenario '$TANGEM_PAY_KYC_STATUS_SCENARIO' to '$kycDeclinedState'") {
setWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO, kycDeclinedState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
}
step("Assert Tangem Pay tile with '$kycRejectedTileText' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTangemPayMainScreen { tileWithSubtitle(kycRejectedTileText).assertIsDisplayed() }
}
}
step("Click on Tangem Pay tile") {
onTangemPayMainScreen { mainScreenTile.clickWithAssertion() }
}
step("Assert KYC rejected sheet is displayed") {
onTangemPayKycSheet {
title(rejectedSheetTitle).assertIsDisplayed()
primaryButtonWithText(goToSupportText).assertIsDisplayed()
}
}
}
}
}

View file

@ -0,0 +1,299 @@
package com.tangem.tests.tangempay
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.onTokenReceiveWarningBottomSheet
import com.tangem.screens.tangempay.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert
import org.junit.Test
@HiltAndroidTest
class TangemPayReissueTest : BaseTestCase() {
private val eligibilityState = "PaeraCustomer"
private val balanceScenario = "tangem_pay_balance_update"
private val balanceInitialState = "InitialBalance"
private val cardReissueScenario = "tangem_pay_card_reissue"
private val cardReissueStartedState = "Started"
private val cardReissueFeeErrorState = "FeeError"
private val reissueOrderScenario = "tangem_pay_reissue_order"
private val reissueOrderStartedState = "Started"
private val reissueOrderCompletedState = "Completed"
private val reissueScenario = "tangem_pay_reissue"
private val reissueCompletedState = "Completed"
private val historyScenario = "tangem_pay_transaction_history"
private val historyInitialEmptyState = "InitialEmpty"
private val historyAfterReissueFeeState = "AfterReissueFee"
@AllureId("9749")
@DisplayName("Tangem Pay: card reissue bottom sheet displays replace card details")
@Test
fun reissueBottomSheetDisplaysReplaceCardDetailsTest() {
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(balanceScenario, balanceInitialState)
setWireMockScenarioState(cardReissueScenario, cardReissueStartedState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(balanceScenario)
resetWireMockScenarioState(cardReissueScenario)
},
).run {
step("Open Tangem Pay card page") { openTangemPayCardPage() }
step("Open the 'Replace card' reissue bottom sheet") { openReissueSheet() }
step("Assert reissue sheet title, description and fee label are displayed") {
onTangemPayReissueSheet {
title.assertIsDisplayed()
description.assertIsDisplayed()
feeLabel.assertIsDisplayed()
}
}
step("Assert replacement fee value is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayReissueSheet { feeValue.assertTextContainsSafe("1.00", substring = true) }
}
}
}
}
@AllureId("9833")
@DisplayName("Tangem Pay: error is shown when reissue cost is not fetched")
@Test
fun reissueFeeErrorShowsErrorWhenFeeRequestFailsTest() {
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(cardReissueScenario, cardReissueFeeErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(cardReissueScenario)
},
).run {
step("Open Tangem Pay card page") { openTangemPayCardPage() }
step("Open the 'Replace card' reissue bottom sheet") { openReissueSheet() }
step("Assert fee unreachable error and 'Refresh' button are displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayReissueSheet {
feeErrorTitle.assertIsDisplayed()
refreshButton.assertIsDisplayed()
}
}
}
}
}
@AllureId("9752")
@DisplayName("Tangem Pay: reissue fee transaction appears in history and details")
@Test
fun reissueFeeTransactionAppearsInHistoryAndDetailsTest() {
val feeMerchantName = "Card replacement fee"
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(balanceScenario, balanceInitialState)
setWireMockScenarioState(cardReissueScenario, cardReissueStartedState)
setWireMockScenarioState(reissueOrderScenario, reissueOrderStartedState)
setWireMockScenarioState(historyScenario, historyInitialEmptyState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(balanceScenario)
resetWireMockScenarioState(cardReissueScenario)
resetWireMockScenarioState(reissueOrderScenario)
resetWireMockScenarioState(historyScenario)
},
).run {
step("Open Tangem Pay card page") { openTangemPayCardPage() }
step("Open the 'Replace card' reissue bottom sheet") { openReissueSheet() }
step("Wait for replacement fee to load") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayReissueSheet { feeValue.assertTextContainsSafe("1.00", substring = true) }
}
}
step("Click on 'Replace card' confirm button") {
onTangemPayReissueSheet { confirmButton.clickWithAssertion() }
}
step("Assert reissue started and the sheet is dismissed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayCardPageScreen { reissueInProgressBlock.assertIsDisplayed() }
}
}
step("Return to the payment account screen") {
device.uiDevice.pressBack()
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayMainScreen { balance.assertIsDisplayed() }
}
}
step("Switch WireMock scenario '$historyScenario' to '$historyAfterReissueFeeState'") {
setWireMockScenarioState(historyScenario, historyAfterReissueFeeState)
}
step("Pull to refresh Tangem Pay") { pullToRefreshTangemPay() }
step("Assert '$feeMerchantName' transaction is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayMainScreen { transactionRowWithText(feeMerchantName).assertIsDisplayed() }
}
}
step("Click on '$feeMerchantName' transaction row") {
onTangemPayMainScreen { transactionRowWithText(feeMerchantName).performClick() }
}
step("Assert fee transaction details are displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayTransactionDetailsSheet {
feeTitle.assertIsDisplayed()
serviceFeesCategory.assertIsDisplayed()
amount.assertTextContainsSafe("1.00", substring = true)
}
}
}
}
}
@AllureId("9743")
@DisplayName("Tangem Pay: card reissue end-to-end replaces card with new details")
@Test
fun reissueEndToEndReplacesCardWithNewDetailsTest() {
val reissuedCardLastDigits = "5353"
val balanceAfterWithdrawState = "AfterWithdraw"
// AfterWithdraw funds the fee check without a customer/me stub, so reissued customer/me wins.
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(balanceScenario, balanceAfterWithdrawState)
setWireMockScenarioState(cardReissueScenario, cardReissueStartedState)
setWireMockScenarioState(reissueOrderScenario, reissueOrderStartedState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(balanceScenario)
resetWireMockScenarioState(cardReissueScenario)
resetWireMockScenarioState(reissueOrderScenario)
resetWireMockScenarioState(reissueScenario)
},
).run {
var oldCardNumber = ""
step("Open Tangem Pay card page") { openTangemPayCardPage() }
step("Read the current card number") {
onTangemPayCardPageScreen { cardNumberShort.assertIsDisplayed() }
onTangemPayCardPageScreen { oldCardNumber = cardNumberShort.extractText() }
}
step("Open the 'Replace card' reissue bottom sheet") { openReissueSheet() }
step("Wait for replacement fee to load") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayReissueSheet { feeValue.assertTextContainsSafe("1.00", substring = true) }
}
}
step("Click on 'Replace card' confirm button") {
onTangemPayReissueSheet { confirmButton.clickWithAssertion() }
}
step("Assert reissue started (sheet dismissed, card is replacing)") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayReissueSheet { confirmButton.assertDoesNotExist() }
onTangemPayCardPageScreen { reissueInProgressBlock.assertIsDisplayed() }
}
}
step("Switch WireMock scenario '$reissueOrderScenario' to '$reissueOrderCompletedState'") {
setWireMockScenarioState(reissueOrderScenario, reissueOrderCompletedState)
}
step("Switch WireMock scenario '$reissueScenario' to '$reissueCompletedState'") {
setWireMockScenarioState(reissueScenario, reissueCompletedState)
}
step("Return to the payment account screen") {
device.uiDevice.pressBack()
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayMainScreen { balance.assertIsDisplayed() }
}
}
step("Refresh until the card leaves the replacing state") {
pullToRefreshTangemPay()
flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
onTangemPayMainScreen { reissueInProgressBanner.assertDoesNotExist() }
}
}
step("Reopen the reissued card") {
onTangemPayMainScreen { cardButton.clickWithAssertion() }
}
var newCardNumber = ""
step("Read the new card number") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayCardPageScreen {
cardNumberShort.assertTextContainsSafe(reissuedCardLastDigits, substring = true)
}
}
onTangemPayCardPageScreen { newCardNumber = cardNumberShort.extractText() }
}
step("Assert the card number changed after reissue") {
Assert.assertNotEquals(oldCardNumber, newCardNumber)
}
}
}
@AllureId("9746")
@DisplayName("Tangem Pay: top up via card reissue when unable to cover fee")
@Test
fun reissueTopUpUnableToCoverFeeAddFundsReceiveTest() {
val depositNetworkName = "Polygon"
setupHooks(
additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(cardReissueScenario, cardReissueStartedState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(cardReissueScenario)
},
).run {
step("Open Tangem Pay card page") { openTangemPayCardPage() }
step("Open the 'Replace card' reissue bottom sheet") { openReissueSheet() }
step("Assert 'Unable to cover fee' state is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayReissueSheet { insufficientFundsTitle.assertIsDisplayed() }
}
}
step("Click on 'Add funds' button") {
onTangemPayReissueSheet { addFundsButton.clickWithAssertion() }
}
step("Assert 'Add funds' sheet shows Swap and Receive options") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTangemPayAddFundsSheet {
swapOption.assertIsDisplayed()
receiveOption.assertIsDisplayed()
}
}
}
step("Click on 'Receive' option") {
onTangemPayAddFundsSheet { receiveOption.clickWithAssertion() }
}
step("Assert deposit info for the '$depositNetworkName' network is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onTokenReceiveWarningBottomSheet {
bottomSheet.assertIsDisplayed()
networkName(depositNetworkName).assertIsDisplayed()
}
}
}
}
}
}

View file

@ -1,5 +1,12 @@
package com.tangem.tests.tangempay
import android.app.Activity
import android.app.Instrumentation.ActivityResult
import android.content.Intent
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.platform.app.InstrumentationRegistry
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
@ -11,11 +18,14 @@ import com.tangem.common.utils.assertClipboardTextEquals
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.scenarios.*
import com.tangem.screens.tangempay.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.hamcrest.CoreMatchers.allOf
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -240,4 +250,75 @@ class TangemPayTest : BaseTestCase() {
}
}
}
@AllureId("9592")
@DisplayName("Tangem Pay: Terms and fees opens the tariffs document")
@Test
fun termsAndFeesOpensTariffsFromMoreActionsMenuTest() {
val eligibilityState = "PaeraCustomer"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
},
).run {
step("Open Tangem Pay") { openTangemPay() }
step("Click on 'More actions' button") {
onTangemPayMainScreen { moreActionsButton.clickWithAssertion() }
}
step("Stub external browser so the tariffs document is not actually opened") {
intending(hasAction(Intent.ACTION_VIEW))
.respondWith(ActivityResult(Activity.RESULT_OK, null))
}
step("Click on 'Terms and fees' menu item") {
onTangemPayMainScreen { termsAndFeesMenuItem.clickWithAssertion() }
}
step("Assert the tariffs document is opened in an external browser") {
intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(TangemPayConstants.TERMS_AND_LIMITS_LINK)))
}
}
}
// iOS-only: Android has no 'Service temporarily unavailable' sheet and no empty-depositAddress guard in Add funds.
@Ignore("[REDACTED_JIRA]")
@AllureId("9557")
@DisplayName("Tangem Pay: service unavailable error when customer has no deposit address")
@Test
fun addFundsShowsServiceUnavailableErrorWhenNoDepositAddressTest() {
val depositAddressScenario = "tangem_pay_deposit_address"
val noDepositAddressState = "NoDepositAddress"
val eligibilityState = "PaeraCustomer"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(depositAddressScenario, noDepositAddressState)
},
additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(depositAddressScenario)
},
).run {
step("Open Tangem Pay") { openTangemPay() }
step("Click on 'Add funds' button") {
onTangemPayMainScreen { topUpButton.clickWithAssertion() }
}
step("Assert 'Service temporarily unavailable' sheet is displayed") {
onTangemPayServiceUnavailableSheet {
title.assertIsDisplayed()
description.assertIsDisplayed()
gotItButton.assertIsDisplayed()
}
}
step("Click on 'Got it' button") {
onTangemPayServiceUnavailableSheet { gotItButton.clickWithAssertion() }
}
step("Assert 'Service temporarily unavailable' sheet is not displayed") {
onTangemPayServiceUnavailableSheet { title.assertDoesNotExist() }
}
}
}
}

View file

@ -0,0 +1,268 @@
package com.tangem.tests.tokenDetails
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.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.assertQrCodeEncodesDisplayedAddress
import com.tangem.scenarios.assertQrCodesMatchForBothAddressTypes
import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openReceiveViaAddFunds
import com.tangem.scenarios.openTokenDetails
import com.tangem.screens.onTokenDetailsScreen
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 TokenDetailsAddressesTest : BaseTestCase() {
@AllureId("4947")
@DisplayName("Token details (address): QR code encodes the displayed address (Bitcoin, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressBitcoinTest() {
val tokenName = "Bitcoin"
setupHooks().run {
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
@AllureId("10218")
@DisplayName("Token details (address): QR code encodes the displayed address (Cosmos)")
@Test
fun qrCodeEncodesDisplayedAddressCosmosTest() {
val tokenName = "Cosmos"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10219")
@DisplayName("Token details (address): QR code encodes the displayed address (Kaspa)")
@Test
fun qrCodeEncodesDisplayedAddressKaspaTest() {
val tokenName = "Kaspa"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
val quotesKaspaState = "Kaspa"
val kaspaUtxoScenario = "kaspa_utxo"
val kaspaUtxoState = "more_than_84_android"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
resetWireMockScenarioState(kaspaUtxoScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesKaspaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesKaspaState)
}
step("Set WireMock scenario: '$kaspaUtxoScenario' to state: '$kaspaUtxoState'") {
setWireMockScenarioState(scenarioName = kaspaUtxoScenario, state = kaspaUtxoState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10215")
@DisplayName("Token details (address): QR code encodes the displayed address (Litecoin, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressLitecoinTest() {
val tokenName = "Litecoin"
val userTokensState = "Litecoin"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton.clickWithAssertion() }
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
@AllureId("10220")
@DisplayName("Token details (address): QR code encodes the displayed address (XDC Network, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressXdcTest() {
val tokenName = "XDC Network"
val userTokensState = "XDC"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton.clickWithAssertion() }
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
@AllureId("10216")
@DisplayName("Token details (address): QR code encodes the displayed address (Hedera)")
@Test
fun qrCodeEncodesDisplayedAddressHederaTest() {
val tokenName = "Hedera"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
},
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10217")
@DisplayName("Token details (address): QR code encodes the displayed address (Ethereum)")
@Test
fun qrCodeEncodesDisplayedAddressEthereumTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Open receive via 'Add funds'") {
openReceiveViaAddFunds()
}
step("Go to QR code bottom sheet") {
goToQrCodeBottomSheet()
}
step("Assert QR code encodes the displayed address") {
assertQrCodeEncodesDisplayedAddress()
}
}
}
@AllureId("10214")
@DisplayName("Token details (address): QR code encodes the displayed address (Decimal Smart Chain, 2 address types)")
@Test
fun qrCodeEncodesDisplayedAddressDecimalTest() {
val tokenName = "Decimal Smart Chain"
val userTokensState = "Decimal"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) },
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open token details for '$tokenName'") {
openTokenDetails(tokenName)
}
step("Click on 'Receive' action") {
onTokenDetailsScreen { receiveButton.clickWithAssertion() }
}
step("Assert QR codes match for both address types") {
assertQrCodesMatchForBothAddressTypes()
}
}
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.tests.tokenDetails
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.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTokenMarketBlock
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 TokenDetailsMarketPriceTests : BaseTestCase() {
@AllureId("301")
@DisplayName("Token details: Market Price block data")
@Test
fun marketPriceBlockDataTest() {
val tokenName = "Dogecoin"
val marketPriceTitle = getResourceString(R.string.markets_common_market_price)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName)
}
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("Assert 'Market Price' block is displayed with title '$marketPriceTitle'") {
onTokenMarketBlock {
block.assertIsDisplayed()
title.assertTextEquals(marketPriceTitle)
}
}
step("Assert price rate is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenMarketBlock { price.assertIsDisplayed() }
}
}
step("Assert 24h price change is displayed") {
onTokenMarketBlock { priceChange.assertIsDisplayed() }
}
step("Assert mini chart is displayed") {
onTokenMarketBlock { chart.assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.tests.tokenDetails
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.DOGECOIN_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.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTxHistoryScreen
import com.tangem.utils.toBriefAddressFormat
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 TokenDetailsTests : BaseTestCase() {
private val txHistoryScenarioName = "dogecoin_tx_history"
@AllureId("304")
@DisplayName("Token details: active outgoing transaction block")
@Test
fun activeOutgoingTransactionBlockTest() {
val tokenName = "Dogecoin"
val currencySymbol = "DOGE"
val txHistoryScenarioState = "UnconfirmedOutgoing"
val sendingTitle = getResourceString(R.string.common_sending)
val recipientBriefAddress = DOGECOIN_RECIPIENT_ADDRESS.toBriefAddressFormat()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(txHistoryScenarioName)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName)
}
step("Set WireMock scenario: '$txHistoryScenarioName' to state: '$txHistoryScenarioState'") {
setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryScenarioState)
}
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("Assert active outgoing '$sendingTitle' transaction is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(sendingTitle).assertIsDisplayed() }
}
}
step("Assert active outgoing transaction status is unconfirmed") {
onTxHistoryScreen { transactionUnconfirmedStatus(sendingTitle).assertIsDisplayed() }
}
step("Assert active outgoing transaction amount is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sendingTitle).assertIsDisplayed()
transactionCurrency(sendingTitle).assertTextEquals(currencySymbol)
}
}
step("Assert active outgoing transaction recipient address '$recipientBriefAddress' is displayed") {
onTxHistoryScreen { transactionAddress(sendingTitle, recipientBriefAddress).assertIsDisplayed() }
}
}
}
}

View file

@ -19,7 +19,6 @@ import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore

View file

@ -0,0 +1,548 @@
package com.tangem.tests.yield
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.pullToRefresh
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreenWithExistingHotWallet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTokenDetailsTopBar
import com.tangem.screens.onTxHistoryScreen
import com.tangem.screens.onYieldSupplyActiveScreen
import com.tangem.screens.onYieldSupplyApproveScreen
import com.tangem.screens.onYieldSupplyPromoScreen
import com.tangem.screens.onYieldSupplyStartEarningScreen
import com.tangem.screens.onYieldSupplyStopEarningScreen
import com.tangem.core.res.R as CoreResR
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
@OptIn(ExperimentalTestApi::class)
@HiltAndroidTest
class YieldModeTest : BaseTestCase() {
@AllureId("7957")
@DisplayName("Yield mode: first-time landing activation with zero balance")
@Test
fun firstTimeLandingActivationZeroBalanceTest() {
val tokenTitle = "USDC"
val apy = "5.24"
val portfolioScenario = "user_tokens_api"
val portfolioState = "YieldUSDCEthereumZeroBalance"
val balancesScenario = "moralis_evm_token_balances_api"
val balancesState = "ZeroUsdcEvmBalances"
val yieldScenario = "yield_supply_status"
val yieldNotActiveState = "NotActive"
val yieldActiveState = "Active"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(portfolioScenario)
resetWireMockScenarioState(balancesScenario)
resetWireMockScenarioState(yieldScenario)
}
).run {
step("Set WireMock scenario: '$portfolioScenario' to state: '$portfolioState'") {
setWireMockScenarioState(scenarioName = portfolioScenario, state = portfolioState)
}
step("Set WireMock scenario: '$balancesScenario' to state: '$balancesState'") {
setWireMockScenarioState(scenarioName = balancesScenario, state = balancesState)
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldNotActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldNotActiveState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase = SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Available yield block' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldSupplyAvailableBlock.assertIsDisplayed() }
}
}
step("Click on 'Available yield block'") {
onTokenDetailsScreen { yieldSupplyAvailableBlock.clickWithAssertion() }
}
step("Click on 'Continue' button") {
onYieldSupplyPromoScreen { continueButton.clickWithAssertion() }
}
step("Hold 'Start earning' button to confirm activation") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyStartEarningScreen {
startEarningButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
startEarningButton.assertIsNotDisplayed()
}
}
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldActiveState)
}
step("Assert 'Yield mode enabled' block is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldModeConnectedTitle.assertIsDisplayed() }
}
}
step("Assert 'Average APY' is displayed in yield block") {
onTokenDetailsScreen { yieldModeApy(apy).assertIsDisplayed() }
}
}
}
@AllureId("4938")
@DisplayName("Yield mode: first-time landing activation")
@Test
fun firstTimeLandingActivationTest() {
val tokenTitle = "USDC"
val apy = "5.24"
val enterTransactionTitle = getResourceString(CoreResR.string.yield_module_transaction_enter)
val portfolioScenario = "user_tokens_api"
val portfolioState = "YieldUSDCEthereum"
val balancesScenario = "moralis_evm_token_balances_api"
val balancesState = "NonZeroEvmBalances"
val yieldScenario = "yield_supply_status"
val yieldNotActiveState = "NotActive"
val yieldActiveState = "Active"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(portfolioScenario)
resetWireMockScenarioState(balancesScenario)
resetWireMockScenarioState(yieldScenario)
}
).run {
step("Set WireMock scenario: '$portfolioScenario' to state: '$portfolioState'") {
setWireMockScenarioState(scenarioName = portfolioScenario, state = portfolioState)
}
step("Set WireMock scenario: '$balancesScenario' to state: '$balancesState'") {
setWireMockScenarioState(scenarioName = balancesScenario, state = balancesState)
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldNotActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldNotActiveState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase = SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Available yield block' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldSupplyAvailableBlock.assertIsDisplayed() }
}
}
step("Click on 'Available yield block'") {
onTokenDetailsScreen { yieldSupplyAvailableBlock.clickWithAssertion() }
}
step("Click on 'Continue' button") {
onYieldSupplyPromoScreen { continueButton.clickWithAssertion() }
}
step("Hold 'Start earning' button to confirm activation") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyStartEarningScreen {
startEarningButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
startEarningButton.assertIsNotDisplayed()
}
}
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldActiveState)
}
step("Perform pull to refresh") {
pullToRefresh()
}
step("Assert 'Yield mode enabled' block is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldModeConnectedTitle.assertIsDisplayed() }
}
}
step("Assert 'Average APY' is displayed in yield block") {
onTokenDetailsScreen { yieldModeApy(apy).assertIsDisplayed() }
}
step("Assert 'Yield mode enabled' transaction is displayed in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen {
transactionItem(enterTransactionTitle).assertIsDisplayed()
}
}
}
}
}
@AllureId("5473")
@DisplayName("Yield mode: top-up for active landing")
@Test
fun topUpActiveLandingTest() {
val tokenTitle = "USDC"
val topUpAmount = "600.00"
val receivedTransactionTitle = getResourceString(CoreResR.string.common_received)
val topUpTransactionTitle = getResourceString(CoreResR.string.yield_module_transaction_topup)
val supplyingNotificationTitle = getResourceString(
CoreResR.string.yield_module_amount_not_transfered_to_aave_title,
topUpAmount,
tokenTitle,
)
val portfolioScenario = "user_tokens_api"
val portfolioState = "YieldUSDCEthereum"
val balancesScenario = "moralis_evm_token_balances_api"
val balancesState = "NonZeroEvmBalances"
val yieldScenario = "yield_supply_status"
val yieldTopUpState = "TopUp"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(portfolioScenario)
resetWireMockScenarioState(balancesScenario)
resetWireMockScenarioState(yieldScenario)
}
).run {
step("Set WireMock scenario: '$portfolioScenario' to state: '$portfolioState'") {
setWireMockScenarioState(scenarioName = portfolioScenario, state = portfolioState)
}
step("Set WireMock scenario: '$balancesScenario' to state: '$balancesState'") {
setWireMockScenarioState(scenarioName = balancesScenario, state = balancesState)
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldTopUpState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldTopUpState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase = SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Not supplied' info icon is displayed in yield block") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { earnBlockTitleIcon.assertIsDisplayed() }
}
}
step("Assert 'Received' transaction is displayed in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(receivedTransactionTitle).assertIsDisplayed() }
}
}
step("Assert 'Supply to Aave' transaction is displayed in history") {
onTxHistoryScreen { transactionItem(topUpTransactionTitle).assertIsDisplayed() }
}
step("Click on 'Yield mode enabled' block") {
onTokenDetailsScreen { yieldSupplyActiveBlock.clickWithAssertion() }
}
step("Assert 'Supplying to Aave' notification is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyActiveScreen { notificationTitle(supplyingNotificationTitle).assertIsDisplayed() }
}
}
}
}
@AllureId("7960")
@DisplayName("Yield mode: granting approval")
@Test
fun grantApprovalTest() {
val tokenTitle = "USDC"
val approveNeededTitle = getResourceString(CoreResR.string.yield_module_approve_needed_notification_title)
val portfolioScenario = "user_tokens_api"
val portfolioState = "YieldUSDCEthereum"
val balancesScenario = "moralis_evm_token_balances_api"
val balancesState = "NonZeroEvmBalances"
val yieldScenario = "yield_supply_status"
val yieldApproveNeededState = "Active"
val yieldApproveGrantedState = "ApproveGranted"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(portfolioScenario)
resetWireMockScenarioState(balancesScenario)
resetWireMockScenarioState(yieldScenario)
}
).run {
step("Set WireMock scenario: '$portfolioScenario' to state: '$portfolioState'") {
setWireMockScenarioState(scenarioName = portfolioScenario, state = portfolioState)
}
step("Set WireMock scenario: '$balancesScenario' to state: '$balancesState'") {
setWireMockScenarioState(scenarioName = balancesScenario, state = balancesState)
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldApproveNeededState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldApproveNeededState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase = SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Approve needed' info icon is displayed in yield block") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { earnBlockTitleIcon.assertIsDisplayed() }
}
}
step("Click on 'Yield mode enabled' block") {
onTokenDetailsScreen { yieldSupplyActiveBlock.clickWithAssertion() }
}
step("Assert 'Approve needed' notification is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyActiveScreen { notificationTitle(approveNeededTitle).assertIsDisplayed() }
}
}
step("Click on 'Approve' button") {
onYieldSupplyActiveScreen { approveButton.clickWithAssertion() }
}
step("Hold 'Confirm' button to confirm approval") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyApproveScreen {
confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
confirmButton.assertIsNotDisplayed()
}
}
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldApproveGrantedState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldApproveGrantedState)
}
step("Perform pull to refresh") {
pullToRefresh()
}
step("Assert 'Approve needed' info icon is not displayed in yield block") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { earnBlockTitleIcon.assertIsNotDisplayed() }
}
}
}
}
@AllureId("4940")
@DisplayName("Yield mode: reopening landing")
@Test
fun reopenLandingActivationTest() {
val tokenTitle = "USDC"
val nativeCoinTitle = "Ethereum"
val apy = "5.24"
val reactivateTransactionTitle = getResourceString(CoreResR.string.yield_module_transaction_reactivate)
val enterTransactionTitle = getResourceString(CoreResR.string.yield_module_transaction_enter)
val portfolioScenario = "user_tokens_api"
val portfolioState = "YieldUSDCEthereum"
val balancesScenario = "moralis_evm_token_balances_api"
val balancesState = "NonZeroEvmBalances"
val yieldScenario = "yield_supply_status"
val yieldNotActiveState = "NotActive"
val yieldActiveState = "Active"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(portfolioScenario)
resetWireMockScenarioState(balancesScenario)
resetWireMockScenarioState(yieldScenario)
}
).run {
step("Set WireMock scenario: '$portfolioScenario' to state: '$portfolioState'") {
setWireMockScenarioState(scenarioName = portfolioScenario, state = portfolioState)
}
step("Set WireMock scenario: '$balancesScenario' to state: '$balancesState'") {
setWireMockScenarioState(scenarioName = balancesScenario, state = balancesState)
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldNotActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldNotActiveState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase = SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Available yield block' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldSupplyAvailableBlock.assertIsDisplayed() }
}
}
step("Click on 'Available yield block'") {
onTokenDetailsScreen { yieldSupplyAvailableBlock.clickWithAssertion() }
}
step("Click on 'Continue' button") {
onYieldSupplyPromoScreen { continueButton.clickWithAssertion() }
}
step("Hold 'Start earning' button to confirm activation") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyStartEarningScreen {
startEarningButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
startEarningButton.assertIsNotDisplayed()
}
}
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldActiveState)
}
step("Perform pull to refresh") {
pullToRefresh()
}
step("Assert 'Yield mode enabled' block is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldModeConnectedTitle.assertIsDisplayed() }
}
}
step("Assert 'Average APY' is displayed in yield block") {
onTokenDetailsScreen { yieldModeApy(apy).assertIsDisplayed() }
}
step("Assert 'Yield mode enabled' transaction is displayed in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(enterTransactionTitle).assertIsDisplayed() }
}
}
step("Click 'Back' button to return to 'Main Screen'") {
onTokenDetailsTopBar { backButton.clickWithAssertion() }
}
step("Click on native coin with name: '$nativeCoinTitle'") {
onMainScreen { tokenWithTitleAndAddress(nativeCoinTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Reactivate Token' transaction is displayed in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(reactivateTransactionTitle).assertIsDisplayed() }
}
}
step("Assert 'Enter protocol' transaction is displayed in history") {
onTxHistoryScreen { transactionItem(enterTransactionTitle).assertIsDisplayed() }
}
}
}
@AllureId("4937")
@DisplayName("Yield mode: closing active landing")
@Test
fun closeActiveLandingTest() {
val tokenTitle = "USDC"
val nativeCoinTitle = "Ethereum"
val exitTransactionTitle = getResourceString(CoreResR.string.yield_module_transaction_exit)
val portfolioScenario = "user_tokens_api"
val portfolioState = "YieldUSDCEthereum"
val balancesScenario = "moralis_evm_token_balances_api"
val balancesState = "NonZeroEvmBalances"
val yieldScenario = "yield_supply_status"
val yieldActiveState = "Active"
val yieldExitedState = "Exited"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(portfolioScenario)
resetWireMockScenarioState(balancesScenario)
resetWireMockScenarioState(yieldScenario)
}
).run {
step("Set WireMock scenario: '$portfolioScenario' to state: '$portfolioState'") {
setWireMockScenarioState(scenarioName = portfolioScenario, state = portfolioState)
}
step("Set WireMock scenario: '$balancesScenario' to state: '$balancesState'") {
setWireMockScenarioState(scenarioName = balancesScenario, state = balancesState)
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldActiveState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldActiveState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase = SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Yield mode enabled' block is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldModeConnectedTitle.assertIsDisplayed() }
}
}
step("Click on 'Yield mode enabled' block") {
onTokenDetailsScreen { yieldSupplyActiveBlock.clickWithAssertion() }
}
step("Click on 'Disable Yield Mode' button") {
onYieldSupplyActiveScreen { stopEarningButton.clickWithAssertion() }
}
step("Hold 'Confirm' button to confirm exit") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onYieldSupplyStopEarningScreen {
confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
confirmButton.assertIsNotDisplayed()
}
}
}
step("Set WireMock scenario: '$yieldScenario' to state: '$yieldExitedState'") {
setWireMockScenarioState(scenarioName = yieldScenario, state = yieldExitedState)
}
step("Assert 'Available yield block' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { yieldSupplyAvailableBlock.assertIsDisplayed() }
}
}
step("Click 'Back' button to return to 'Main Screen'") {
onTokenDetailsTopBar { backButton.clickWithAssertion() }
}
step("Click on native coin with name: '$nativeCoinTitle'") {
onMainScreen { tokenWithTitleAndAddress(nativeCoinTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Yield mode disabled' transaction is displayed in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(exitTransactionTitle).assertIsDisplayed() }
}
}
}
}
}

View file

@ -12,11 +12,13 @@ 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.common.wallets.UserWalletsListRepository
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient
import com.tangem.tap.common.log.TangemLoggingInitializer
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@ -58,4 +60,8 @@ interface ApplicationEntryPoint {
fun getDeviceRegistrar(): DeviceRegistrar
fun getAuthFeatureToggles(): AuthFeatureToggles
fun getWalletRegistrationLauncher(): WalletRegistrationLauncher
fun getUserWalletsListRepository(): UserWalletsListRepository
}

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
@ -28,9 +29,11 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import com.appsflyer.AppsFlyerLib
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY
import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter
import com.tangem.common.routing.deeplink.PushDeeplinkPolicy
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.di.RootAppComponentContext
@ -215,6 +218,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
if (BuildConfig.TESTER_MENU_ENABLED) {
lifecycle.addObserver(testerMenuLauncher.launchOnKeyEventObserver)
testerMenuLauncher.registerTesterMenuShortcut()
}
if (intent != null) {
@ -367,6 +371,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
super.onNewIntent(intent)
TangemLogger.i("onNewIntent: data=${intent.data}, extras=${intent.extras?.keySet()}")
// Warm start: let the AppsFlyer SDK resolve a OneLink delivered while the app is already running.
AppsFlyerLib.getInstance().performOnDeepLinking(intent, this)
val isFromPush = intent.extras?.containsKey(OPENED_FROM_GCM_PUSH) == true
if (isFromPush) {
analyticsEventsHandler.send(Push.PushNotificationOpened())
@ -410,25 +417,33 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
}
private fun handleDeepLink(intent: Intent, isFromOnNewIntent: Boolean) {
val deepLinkExtras = PayloadToDeeplinkConverter.convertBundle(intent.extras)?.toUri()
val externalDeepLink = intent.data
val extrasDeepLink = PayloadToDeeplinkConverter.convertBundle(intent.extras)?.toUri()
val webLink = intent.getStringExtra(WEBLINK_KEY)
val receivedDeepLink = intent.data ?: deepLinkExtras
when {
receivedDeepLink != null -> {
deeplinkFactory.handleDeeplink(
deeplinkUri = receivedDeepLink,
coroutineScope = lifecycleScope,
isFromOnNewIntent = isFromOnNewIntent,
)
}
// External deep links (browser / universal links) arrive via intent.data — never gated.
externalDeepLink != null -> launchDeepLink(externalDeepLink, isFromOnNewIntent)
// A deep link reconstructed from a notification payload must pass the push policy. If it is
// present but blocked, stop — do not fall back to another payload-supplied field (e.g. webLink).
extrasDeepLink != null ->
if (PushDeeplinkPolicy.isOpenableFromPush(scheme = extrasDeepLink.scheme, host = extrasDeepLink.host)) {
launchDeepLink(extrasDeepLink, isFromOnNewIntent)
}
webLink?.uriValidate() == true -> {
urlOpener.openUrl(webLink)
}
}
}
private fun launchDeepLink(deeplinkUri: Uri, isFromOnNewIntent: Boolean) {
deeplinkFactory.handleDeeplink(
deeplinkUri = deeplinkUri,
coroutineScope = lifecycleScope,
isFromOnNewIntent = isFromOnNewIntent,
)
}
private fun sendStakingUnsubmittedHashes() {
lifecycleScope.launch {
sendUnsubmittedHashesUseCase.invoke()

View file

@ -20,6 +20,7 @@ import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.common.LogConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
@ -34,6 +35,8 @@ import com.tangem.tap.common.analytics.handlers.customerio.CustomerIoAnalyticsHa
import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler
import com.tangem.tap.common.images.createCoilImageLoader
import com.tangem.tap.common.log.TangemLoggingInitializer
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
@ -104,6 +107,12 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val authFeatureToggles: AuthFeatureToggles
get() = entryPoint.getAuthFeatureToggles()
private val walletRegistrationLauncher: WalletRegistrationLauncher
get() = entryPoint.getWalletRegistrationLauncher()
private val userWalletsListRepository: UserWalletsListRepository
get() = entryPoint.getUserWalletsListRepository()
// endregion
private val appScope = MainScope()
@ -152,6 +161,18 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
deviceKeyManager.generateIfMissing()
deviceRegistrar.register()
.onLeft { error -> TangemLogger.w("Device registration deferred: $error") }
.onRight {
// Safety net: register any MOBILE wallets that missed it (created before the
// toggle was on, or after a transient failure). Requires the DPoP session from
// device registration, so it runs only once that succeeded. Best-effort —
// loading the wallet list (userWalletsSync) can throw, which must not crash
// startup, so the whole retry is guarded.
runSuspendCatching {
walletRegistrationLauncher.retryMobileRegistrations(
userWalletsListRepository.userWalletsSync(),
)
}.onFailure { error -> TangemLogger.e("Mobile wallet registration retry failed", error) }
}
}
}
walletsRepository = entryPoint.getWalletsRepository()

View file

@ -13,17 +13,9 @@ class AppsFlyerDeepLinkListener @Inject constructor(
override fun onDeepLinking(p0: DeepLinkResult) {
when (p0.status) {
DeepLinkResult.Status.FOUND -> {
referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
}
DeepLinkResult.Status.NOT_FOUND -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.i("No deep link found")
}
DeepLinkResult.Status.ERROR -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.e("Deep link error: ${p0.error}")
}
DeepLinkResult.Status.FOUND -> referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
DeepLinkResult.Status.NOT_FOUND -> TangemLogger.i("No deep link found")
DeepLinkResult.Status.ERROR -> TangemLogger.e("Deep link error: ${p0.error}")
}
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.tap.common.analytics.appsflyer
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reactively routes AppsFlyer deep links persisted by [AppsFlyerReferralParamsHandler].
*
* To add a deep link: add it to [AppsFlyerDeeplink] and a branch in [onDeeplinkPending] (the `when` is exhaustive).
*/
@Singleton
class AppsFlyerDeeplinkRouter @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val appRouter: AppRouter,
) {
fun observe(scope: CoroutineScope, currentRoute: Flow<AppRoute?>) {
combine(
appsFlyerStore.observeNavigationDeeplink(),
currentRoute.distinctUntilChanged(),
) { deepLinkValue, route ->
if (deepLinkValue != null && route != null) deepLinkValue to route else null
}
.filterNotNull()
.onEach { (deepLinkValue, route) -> onDeeplinkPending(deepLinkValue, route) }
.launchIn(scope)
}
private suspend fun onDeeplinkPending(deepLinkValue: String, currentRoute: AppRoute) {
when (AppsFlyerDeeplink.from(deepLinkValue)) {
AppsFlyerDeeplink.TangemPayMobileOnboarding -> routeTangemPayOnboarding(currentRoute)
AppsFlyerDeeplink.Referral -> routeReferral(currentRoute)
null -> TangemLogger.i("Ignoring unknown AppsFlyer deep link value: $deepLinkValue")
}
}
private suspend fun routeTangemPayOnboarding(currentRoute: AppRoute) {
// Not on an idle entry screen yet: keep the deep link pending, re-evaluate on next route change.
if (!isIdleEntryPoint(currentRoute)) return
if (userWalletsListRepository.userWalletsSync().isNotEmpty()) {
TangemLogger.i("[TangemPay][HWO] Routing AppsFlyer deep link to Tangem Pay onboarding")
// Authorized: push onto the wallet screen so Back returns to it.
appRouter.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink))
} else {
TangemLogger.i("[TangemPay][HWO] Routing AppsFlyer deep link to hot wallet onboarding")
// Not authorized: open onboarding as the root, skipping Home/stories (original cold-start flow).
appRouter.replaceAll(AppRoute.TangemPayHotWalletOnboarding)
}
// One-shot: consume the deep link once routed so the user isn't forced back here on relaunch.
clearAppsFlyerDeeplinkUseCase()
}
private suspend fun routeReferral(currentRoute: AppRoute) {
// Referral targets fresh installs: from an idle entry screen, go straight to hot wallet creation
// (skips stories). The deep link is NOT cleared here — it stays as referral attribution (read by
// IsReferralInstallUseCase, cleared on wallet creation); replaceAll keeps it off the back stack.
if (!isIdleEntryPoint(currentRoute)) return
if (userWalletsListRepository.userWalletsSync().isNotEmpty()) return
TangemLogger.i("[Referral] Routing AppsFlyer referral deep link to hot wallet creation")
appRouter.replaceAll(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet))
}
}
// Route only from idle entry screens so an in-progress flow (scan, KYC, onboarding…) isn't interrupted.
internal fun isIdleEntryPoint(currentRoute: AppRoute?): Boolean =
currentRoute is AppRoute.Home || currentRoute is AppRoute.Stories || currentRoute is AppRoute.Wallet

View file

@ -1,12 +1,11 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@ -19,8 +18,6 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private val coroutineScope: AppCoroutineScope,
) {
private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) {
handle(
deepLinkValue = params[DEEP_LINK_VALUE] as? String,
@ -35,40 +32,15 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
deepLinkDeferred.complete(deepLink.deepLinkValue)
}
fun handleNoDeeplink() {
deepLinkDeferred.complete(null)
}
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
appsFlyerStore.getDeeplink(deeplinkSource)?.let { return it }
val expectedValue = when (deeplinkSource) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
AppsFlyerDeeplinkSource.Referral -> REFERRAL_DEEP_LINK_VALUE
}
val resolvedValue = deepLinkDeferred.await().takeIf { it == expectedValue }
// The deep link may have been persisted to the store while we were awaiting (e.g. from
// conversion-data handling, which stores the deep link but doesn't complete the deferred).
return resolvedValue ?: appsFlyerStore.getDeeplink(deeplinkSource)
}
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
TangemLogger.i("AppsFlyer deeplink received: value=$deepLinkValue")
when (deepLinkValue) {
REFERRAL_DEEP_LINK_VALUE -> handleReferral(deepLinkSub1, deepLinkSub2)
TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE -> handleTangemPayHotWalletOnboarding(deepLinkValue)
else -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
}
}
private fun handleTangemPayHotWalletOnboarding(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding, deepLinkValue)
TangemLogger.i("[TangemPay][HWO] Deep link stored")
when (AppsFlyerDeeplink.from(deepLinkValue)) {
AppsFlyerDeeplink.Referral -> handleReferral(deepLinkSub1, deepLinkSub2)
AppsFlyerDeeplink.TangemPayMobileOnboarding ->
storeNavigationDeeplink(AppsFlyerDeeplink.TangemPayMobileOnboarding.deepLinkValue)
null -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
}
}
@ -76,9 +48,7 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
@Suppress("NullableToStringCall")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, REFERRAL_DEEP_LINK_VALUE)
}
storeNavigationDeeplink(AppsFlyerDeeplink.Referral.deepLinkValue)
if (!isValidParam(deepLinkSub1)) {
TangemLogger.e("Deeplink conversion data is invalid")
@ -97,6 +67,13 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true)
}
private fun storeNavigationDeeplink(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeNavigationDeeplink(deepLinkValue)
TangemLogger.i("AppsFlyer navigation deep link stored: $deepLinkValue")
}
}
private fun storeConversionData(refcode: String, campaign: String?) {
coroutineScope.launch {
appsFlyerStore.storeIfAbsent(
@ -107,9 +84,6 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private companion object {
const val REFERRAL_DEEP_LINK_VALUE = "referral"
const val TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE = "tpay_mobileonboard"
const val DEEP_LINK_VALUE = "deep_link_value"
const val DEEP_LINK_SUB_1 = "deep_link_sub1"
const val DEEP_LINK_SUB_2 = "deep_link_sub2"

View file

@ -36,9 +36,9 @@ class AppsFlyerClient @AssistedInject constructor(
setAppId(context.packageName)
setDebugLog(true)
subscribeForDeepLink(appsFlyerDeepLinkListener)
init(apiKey, tangemAFConversionListener, context)
subscribeForDeepLink(appsFlyerDeepLinkListener)
setOneLinkCustomDomain(BRANDED_ONELINK_DOMAIN)
TangemLogger.i("Starting AppsFlyer SDK")
start(context, apiKey, InitializationListener)
@ -100,4 +100,9 @@ class AppsFlyerClient @AssistedInject constructor(
interface Factory {
fun create(apiKey: String): AppsFlyerClient
}
private companion object {
// Branded AppsFlyer OneLink domain (matches the join.tangem.com App Link filter in AndroidManifest).
const val BRANDED_ONELINK_DOMAIN = "join.tangem.com"
}
}

View file

@ -34,18 +34,11 @@ class CustomerIoAnalyticsHandler(
class Builder : AnalyticsHandlerBuilder {
override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler? {
val cdpApiKey = data.config.customerIoCdpApiKey
return if (data.logConfig.isCustomerIoLogEnabled) {
CustomerIoAnalyticsHandler(client = CustomerIoLogClient())
} else if (!cdpApiKey.isNullOrBlank()) {
CustomerIoAnalyticsHandler(
client = CustomerIoClient(
application = data.application,
cdpApiKey = cdpApiKey,
),
)
} else {
null
}
if (cdpApiKey.isNullOrBlank()) return null
return CustomerIoAnalyticsHandler(
client = CustomerIoClient(application = data.application, cdpApiKey = cdpApiKey),
)
}
}
}

View file

@ -1,23 +0,0 @@
package com.tangem.tap.common.analytics.handlers.customerio
import com.tangem.utils.logging.TangemLogger
/**
* Log client for Customer.io (used in debug mode).
*
* Logs all operations to Timber instead of sending them to Customer.io.
*/
internal class CustomerIoLogClient : CustomerIoAnalyticsClient {
private var userId: String? = null
override fun setUserId(userId: String) {
this.userId = userId
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("identify: userId=$userId")
}
override fun clearUserId() {
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("clearIdentify: previous userId=$userId")
this.userId = null
}
}

View file

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter
import com.tangem.common.routing.deeplink.PushDeeplinkPolicy
import com.tangem.utils.extensions.uriValidate
import javax.inject.Inject
@ -21,7 +22,9 @@ internal class PushMessageHandler @Inject constructor(
) {
fun onMessageReceived(data: Map<String, String>) {
val deeplink = PayloadToDeeplinkConverter.convert(data)?.toUri() ?: return
val deeplink = PayloadToDeeplinkConverter.convert(data)?.toUri()
?.takeIf { PushDeeplinkPolicy.isOpenableFromPush(scheme = it.scheme, host = it.host) }
?: return
val queryParams = deeplink.getQueryParams()
when (deeplink.host) {
DeepLinkRoute.TokenDetails.host -> tokenDetailsPushHandler.handle(queryParams)

View file

@ -1,18 +0,0 @@
package com.tangem.tap.core.ui
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.ui.DesignFeatureToggles
import javax.inject.Inject
class DefaultDesignFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : DesignFeatureToggles {
override val isRedesignEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.APP_REDESIGN_ENABLED)
override val isWarningsRefactoringEnabled: Boolean
get() = isRedesignEnabled &&
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_14829_WARNINGS_REFACTORING_ENABLED)
}

View file

@ -1,13 +1,17 @@
package com.tangem.tap.data
import android.content.Context
import android.os.Build
import com.tangem.utils.info.AppInfoProvider
import com.tangem.wallet.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider {
internal class DefaultAppInfoProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : AppInfoProvider {
override val platform: String
get() = "Android"
override val device: String
@ -18,6 +22,8 @@ internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider {
get() = Build.VERSION.SDK_INT
override val language: String
get() = Locale.getDefault().toLanguageTag()
override val deviceScale: Float
get() = context.resources.displayMetrics.density
override val timezone: String
get() = TimeZone.getDefault().id
override val appVersion: String = BuildConfig.VERSION_NAME

View file

@ -4,15 +4,16 @@ 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.model.PendingOfframp.Companion.EXPIRY_MS
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.flow.first
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Default implementation of [OfframpRepository].
@ -27,7 +28,7 @@ internal class DefaultOfframpRepository(
private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter()
private val converter = PendingOfframpEntryConverter()
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
@ -71,13 +72,17 @@ internal class DefaultOfframpRepository(
entry.requestId == requestId &&
entry.userWalletId == userWalletId.stringValue &&
entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS
!entry.isExpired(now)
}
// Keep the matched record so the same redirect can be followed again until it expires; only prune the
// expired ones. The record is dropped naturally once it ages past EXPIRY_MS.
stored.filterNotExpired(now)
}
matched?.let(pendingOfframpConverter::convert)
matched?.let(converter::convert)
}
override suspend fun getAllStoredOfframps(): List<PendingOfframp> = withContext(dispatchers.io) {
pendingOfframpStore.data.first().map(converter::convert)
}
// Returns the same instance when nothing is expired, so DataStore.updateData sees an unchanged value and skips
@ -85,7 +90,5 @@ internal class DefaultOfframpRepository(
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
if (none { now - it.createdAt >= EXPIRY_MS }) this else filter { now - it.createdAt < EXPIRY_MS }
private companion object {
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
private fun PendingOfframpEntry.isExpired(now: Long): Boolean = converter.convert(this).isExpired(now)
}

View file

@ -1,305 +0,0 @@
package com.tangem.tap.data
import android.content.Context
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.data.pay.entity.WithdrawStoreData
import com.tangem.data.pay.util.WithdrawStateConverter
import com.tangem.data.pay.util.WithdrawStoreDataConverter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val AUTH_TOKENS_DEFAULT_KEY = "tangem_pay_default_key"
private const val WITHDRAW_ORDER_ID_KEY = "tangem_pay_withdraw_order_id_key"
@Suppress("TooManyFunctions")
@Singleton
internal class DefaultTangemPayStorage @Inject constructor(
@ApplicationContext applicationContext: Context,
@NetworkMoshi moshi: Moshi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
) : TangemPayStorage {
private val secureStorage by lazy {
AndroidSecureStorageV2(
appContext = applicationContext,
useStrongBox = false,
name = "tangem_pay_storage",
)
}
private val tokensAdapter by lazy { moshi.adapter(TangemPayAuthTokens::class.java) }
private val withdrawStoreDataConverter by lazy { WithdrawStoreDataConverter() }
private val withdrawStateConverter by lazy { WithdrawStateConverter() }
private val listType by lazy {
Types.newParameterizedType(List::class.java, WithdrawStoreData::class.java)
}
private val mapType by lazy {
Types.newParameterizedType(Map::class.java, String::class.java, listType)
}
private val adapter: JsonAdapter<Map<String, List<WithdrawStoreData>>> by lazy {
appPreferencesStore.moshi.adapter(mapType)
}
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
appPreferencesStore
.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), customerWalletAddress)
}
override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId),
)
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) =
withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens)
secureStorage.store(
json.encodeToByteArray(throwOnInvalidSequence = true),
createAuthTokensKey(customerWalletAddress),
)
}
override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? {
return withContext(dispatcherProvider.io) {
val authTokens = secureStorage.get(createAuthTokensKey(customerWalletAddress))
?.decodeToString(throwOnInvalidSequence = true)
?.let(tokensAdapter::fromJson)
authTokens?.let { tokens ->
if (tokens.idempotencyKey == null) {
val newAuthTokens = tokens.copy(idempotencyKey = UUID.randomUUID().toString())
storeAuthTokens(customerWalletAddress, newAuthTokens)
newAuthTokens
} else {
tokens
}
}
}
}
override suspend fun clearAuthTokens(customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
}
override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), orderId)
}
override suspend fun getOrderId(customerWalletAddress: String): String? {
return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress))
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress),
) == true
}
override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone)
}
override suspend fun clearOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) {
appPreferencesStore.store(
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
value = vaOrderId,
)
}
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
).takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
appPreferencesStore.store(
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId),
isPaeraCustomer,
)
}
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId))
}
override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.plus(createWithdrawOrderIdKey(userWalletId) to orderId)
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]
?.let(adapter::fromJson)
.orEmpty()
val newItem = withdrawStoreDataConverter.convert(data)
val currentList = currentMap[walletKey].orEmpty()
val updatedList = buildList(currentList.size + 1) {
for (item in currentList) { if (item.orderId != newItem.orderId) add(item) }
add(newItem)
}
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] =
adapter.toJson(currentMap + (walletKey to updatedList))
}
}
override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? {
val orders = appPreferencesStore.getObjectMapSync<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
return orders[createWithdrawOrderIdKey(userWalletId)]
}
override suspend fun getWithdrawOrders(userWalletId: UserWalletId): List<TangemPayWithdrawState> {
val map = appPreferencesStore.data.firstOrNull()
?.get(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)?.let(adapter::fromJson).orEmpty()
return map[createWithdrawOrderIdKey(userWalletId)].orEmpty().map(withdrawStateConverter::convert)
}
override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.minus(createWithdrawOrderIdKey(userWalletId))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
.orEmpty()
val currentList = currentMap[walletKey].orEmpty()
val updatedList = buildList(currentList.size) {
for (item in currentList) {
if (item.orderId != orderId) add(item)
}
}
val updatedMap = if (updatedList.isEmpty()) {
currentMap - walletKey
} else {
currentMap + (walletKey to updatedList)
}
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
}
}
override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), hide)
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId),
) == true
}
override suspend fun storeTangemPayEligibility(eligibility: Set<String>) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
value = eligibility,
)
}
}
override suspend fun getTangemPayEligibility(): Set<String> {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
default = emptySet(),
)
}
}
override suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayDeactivatedKey(userWalletId), true)
}
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
val key = PreferencesKeys.getTangemPayDeactivatedKey(userWalletId)
return appPreferencesStore.getSyncOrNull(key) == true
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
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"
private fun createWithdrawOrderIdKey(userWalletId: UserWalletId): String = "${WITHDRAW_ORDER_ID_KEY}_$userWalletId"
}

View file

@ -13,6 +13,8 @@ import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallenge
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import dagger.Lazy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -36,6 +38,7 @@ internal class TangemSdkManagerModule {
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository,
walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
MockTangemSdkManager(resources = context.resources)
@ -50,6 +53,7 @@ internal class TangemSdkManagerModule {
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository,
walletRegistrationLauncher = walletRegistrationLauncher,
)
}
}

View file

@ -4,7 +4,6 @@ import androidx.compose.material3.SnackbarHostState
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.DefaultUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHostState
import com.tangem.core.ui.haptic.VibratorHapticManager
@ -25,7 +24,6 @@ internal object UiDependenciesModule {
fun provideUiDependencies(
vibratorHapticManager: VibratorHapticManager,
appThemeModeHolder: AppThemeModeHolder,
designFeatureToggles: DesignFeatureToggles,
): UiDependencies {
return object : UiDependencies {
override val vibratorHapticManager = vibratorHapticManager
@ -33,7 +31,6 @@ internal object UiDependenciesModule {
override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState()
override val globalTopSnackbarHostState: TangemTopSnackbarHostState = TangemTopSnackbarHostState()
override val eventMessageHandler: EventMessageHandler = EventMessageHandler()
override val designFeatureToggles: DesignFeatureToggles = designFeatureToggles
}
}

View file

@ -7,6 +7,8 @@ import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.AppStoreOpener
import com.tangem.core.navigation.url.DefaultAppStoreOpener
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.finisher.AndroidAppFinisher
@ -35,6 +37,10 @@ internal interface UtilsModule {
@Singleton
fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider
@Binds
@Singleton
fun bindAppStoreOpener(impl: DefaultAppStoreOpener): AppStoreOpener
companion object {
@Provides

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