Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-21 09:07:11 +03:00
parent 64b094c8e7
commit 1dc70cf918
6 changed files with 130 additions and 71 deletions

View file

@ -20,6 +20,7 @@ import com.tangem.common.constants.TestConstants.ALLURE_LABEL_NAME
import com.tangem.common.constants.TestConstants.ALLURE_LABEL_VALUE import com.tangem.common.constants.TestConstants.ALLURE_LABEL_VALUE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.rules.ApiEnvironmentRule import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.data.pay.TangemPayMockControl
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
@ -111,6 +112,9 @@ abstract class BaseTestCase : TestCase(
additionalBeforeSection: () -> Unit = {}, additionalBeforeSection: () -> Unit = {},
additionalAfterSection: () -> Unit = {}, additionalAfterSection: () -> Unit = {},
) = before { ) = before {
// Reset opt-in Tangem Pay mock switches so a previous Tangem Pay test can't leak an active Payment
// account into the next (e.g. generic openMainScreen) test running in the same process.
TangemPayMockControl.reset()
Allure.label(ALLURE_LABEL_NAME, ALLURE_LABEL_VALUE) Allure.label(ALLURE_LABEL_NAME, ALLURE_LABEL_VALUE)
// Setup WireMock redirect for CI with local WireMock instances // Setup WireMock redirect for CI with local WireMock instances
val wiremockUrl = InstrumentationRegistry.getArguments().getString(WIREMOCK_BASE_URL_ARG) val wiremockUrl = InstrumentationRegistry.getArguments().getString(WIREMOCK_BASE_URL_ARG)
@ -180,6 +184,16 @@ abstract class BaseTestCase : TestCase(
fun waitForIdle() = composeTestRule.waitForIdle() fun waitForIdle() = composeTestRule.waitForIdle()
/**
* Opts the current test's wallet into the Tangem Pay mock so it is treated as an existing customer
* (an active Payment account appears). MUST be called before the wallet is imported, so the first
* payment-account status fetch observes it. Paired with the [TangemPayMockControl] reset in [setupHooks],
* which restores the Payment-free default for every other (e.g. generic openMainScreen) test.
*/
fun markExistingTangemPayCustomer() {
TangemPayMockControl.hasTangemPayInWallet = true
}
/** /**
* Waits until [block] stops throwing (or [timeoutMillis] elapses). Use in scenario (BaseTestCase extension) * Waits until [block] stops throwing (or [timeoutMillis] elapses). Use in scenario (BaseTestCase extension)
* code where flakySafely is unavailable; in test bodies prefer flakySafely. * code where flakySafely is unavailable; in test bodies prefer flakySafely.

View file

@ -12,6 +12,8 @@ import com.tangem.screens.tangempay.*
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openTangemPay() { fun BaseTestCase.openTangemPay() {
// Opt this wallet into the Tangem Pay mock (existing customer) before importing the wallet.
markExistingTangemPayCustomer()
step("Import hot wallet from Tangem Pay seed phrase (with access code)") { step("Import hot wallet from Tangem Pay seed phrase (with access code)") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE) openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE)
} }

View file

@ -35,6 +35,8 @@ class TangemPayOnboardingKycTest : BaseTestCase() {
val paeraCustomerState = "PaeraCustomer" val paeraCustomerState = "PaeraCustomer"
setupHooks( setupHooks(
// Existing customer: opt into the Tangem Pay mock so the Payment account (tile) appears.
additionalBeforeSection = { markExistingTangemPayCustomer() },
additionalAfterSection = { additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
}, },
@ -134,6 +136,9 @@ class TangemPayOnboardingKycTest : BaseTestCase() {
val viewStatusText = getResourceString(CoreResR.string.tangempay_kyc_in_progress_notification_button) val viewStatusText = getResourceString(CoreResR.string.tangempay_kyc_in_progress_notification_button)
setupHooks( setupHooks(
// Existing customer: the tile must appear so its KYC-status subtitle can be asserted.
// The KYC status itself comes from the WireMock TANGEM_PAY_KYC_STATUS_SCENARIO via getCustomerInfo.
additionalBeforeSection = { markExistingTangemPayCustomer() },
additionalAfterSection = { additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO)
@ -187,6 +192,9 @@ class TangemPayOnboardingKycTest : BaseTestCase() {
val goToSupportText = getResourceString(CoreResR.string.tangempay_go_to_support) val goToSupportText = getResourceString(CoreResR.string.tangempay_go_to_support)
setupHooks( setupHooks(
// Existing customer: the tile must appear so its KYC-status subtitle can be asserted.
// The KYC status itself comes from the WireMock TANGEM_PAY_KYC_STATUS_SCENARIO via getCustomerInfo.
additionalBeforeSection = { markExistingTangemPayCustomer() },
additionalAfterSection = { additionalAfterSection = {
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO)

View file

@ -2,10 +2,18 @@ package com.tangem.datasource.utils
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
/** /**
* OkHttp interceptor that redirects requests from wiremock.tests-d.com to a local WireMock instance. * OkHttp interceptor that redirects requests from wiremock.tests-d.com to a local WireMock instance.
*
* When a request hits the tangem-api-mocks catch-all (an unmocked endpoint HTTP 404 with the
* [MOCKED_ENDPOINT_NOT_CONFIGURED_MARKER] body), it folds the failing endpoint (method + URL) into the
* response body. That body becomes [com.tangem.datasource.api.common.response.ApiResponseError.HttpException.errorBody]
* (see ResponseExt), whose data-class `toString()` is what appears in a failing test's stack trace so the
* exact missing mapping is named instead of an opaque `HttpException(404, errorBody={"error":"not_found",})`.
*/ */
class WireMockRedirectInterceptor : Interceptor { class WireMockRedirectInterceptor : Interceptor {
@ -17,25 +25,64 @@ class WireMockRedirectInterceptor : Interceptor {
val host = request.url.host val host = request.url.host
val sanitizedOverride = override.trimEnd('/') val sanitizedOverride = override.trimEnd('/')
if (host == WIREMOCK_REMOTE_HOST) { val redirectedRequest = when {
host == WIREMOCK_REMOTE_HOST -> {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride) val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride)
TangemLogger.d("WireMockRedirect: $url -> $newUrl") TangemLogger.d("WireMockRedirect: $url -> $newUrl")
return chain.proceed(request.newBuilder().url(newUrl).build()) request.newBuilder().url(newUrl).build()
} }
host in REDIRECTABLE_THIRD_PARTY_HOSTS -> {
if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) {
val newUrl = url.replace("https://$host", "$sanitizedOverride/$host") val newUrl = url.replace("https://$host", "$sanitizedOverride/$host")
TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl") TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl")
return chain.proceed(request.newBuilder().url(newUrl).build()) request.newBuilder().url(newUrl).build()
}
else -> request
} }
return chain.proceed(request) val response = chain.proceed(redirectedRequest)
return response.withEndpointInMockNotConfiguredError(redirectedRequest)
} }
/**
* If [this] is the tangem-api-mocks catch-all 404 ("Mocked endpoint not configured"), rewrite the error body
* to also carry the failing endpoint, so it surfaces in the resulting `HttpException.errorBody`. Non-404
* responses (incl. the POST JSON-RPC "unreachable" catch-all, which is handled gracefully and must not be
* altered) are returned untouched.
*/
private fun Response.withEndpointInMockNotConfiguredError(request: Request): Response {
if (code != HTTP_NOT_FOUND) return this
val original = runCatching { peekBody(PEEK_LIMIT_BYTES).string() }.getOrNull() ?: return this
if (!original.contains(MOCKED_ENDPOINT_NOT_CONFIGURED_MARKER)) return this
val endpoint = "${request.method} ${request.url}".jsonEscaped()
val enrichedJson = original.indexOf('{').let { open ->
if (open < 0) {
"""{"message":"$MOCKED_ENDPOINT_NOT_CONFIGURED_MARKER","endpoint":"$endpoint"}"""
} else {
original.substring(0, open + 1) + """"endpoint":"$endpoint",""" + original.substring(open + 1)
}
}
val contentType = body?.contentType()
body?.close()
return newBuilder().body(enrichedJson.toResponseBody(contentType)).build()
}
private fun String.jsonEscaped(): String = replace("\\", "\\\\").replace("\"", "\\\"")
companion object { companion object {
private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com" private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com"
private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST" private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST"
private const val HTTP_NOT_FOUND = 404
/** Bounded peek so a large real response is never fully buffered just to look for the marker. */
private const val PEEK_LIMIT_BYTES = 4_096L
/** Emitted by the tangem-api-mocks catch-all mapping for any endpoint without an explicit mapping. */
private const val MOCKED_ENDPOINT_NOT_CONFIGURED_MARKER = "Mocked endpoint not configured"
/** /**
* Upstream hosts that have no other override knob and should be funnelled into WireMock * Upstream hosts that have no other override knob and should be funnelled into WireMock
* when [overriddenBaseUrl] is set. Each matched URL becomes `<override>/<host>/<original-path>`, * when [overriddenBaseUrl] is set. Each matched URL becomes `<override>/<host>/<original-path>`,

View file

@ -0,0 +1,30 @@
package com.tangem.data.pay
/**
* Test-only switch for the mocked Tangem Pay graph.
*
* Kept in the `main` source set (not `mocked`) on purpose: it is read only by the `mocked`-only
* [com.tangem.data.pay.repository.MockAwareOnboardingRepository], but it is also written from `app`
* `androidTest` sources, which are shared across build-type variants. Restricting it to the `mocked` source set
* would leave it off the classpath of any non-mocked androidTest variant and break their compilation. Being in
* `main`, it is unreferenced in production/release (only the mocked repository reads it) and is dropped by R8.
*
* [hasTangemPayInWallet] gates whether a wallet is treated as an existing Tangem Pay customer. It defaults to
* `false` so that the many generic `openMainScreen*` UI tests keep a Payment-account-free wallet (and thus stay
* out of accounts mode). Tangem Pay scenarios opt in by flipping it to `true` before the wallet is loaded; the
* value is reset to the default at the start of every test in `BaseTestCase.setupHooks`.
*
* @see com.tangem.data.pay.repository.MockAwareOnboardingRepository
*/
object TangemPayMockControl {
private const val DEFAULT_HAS_TANGEM_PAY_IN_WALLET = false
@Volatile
var hasTangemPayInWallet: Boolean = DEFAULT_HAS_TANGEM_PAY_IN_WALLET
/** Restores every switch to its default. Called between tests to prevent state leaking across the process. */
fun reset() {
hasTangemPayInWallet = DEFAULT_HAS_TANGEM_PAY_IN_WALLET
}
}

View file

@ -3,28 +3,28 @@ package com.tangem.data.pay.repository
import arrow.core.Either import arrow.core.Either
import arrow.core.right import arrow.core.right
import com.tangem.core.error.UniversalError import com.tangem.core.error.UniversalError
import com.tangem.data.pay.TangemPayMockControl
import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.error.VisaApiError
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* In MOCK env returns canned onboarding data so the Payment Account shows up as fully loaded on the main * In MOCK env only skips the local-storage / NFC-signing enrollment steps (order ids, initial data). The
* screen (the entry point to the TangemPay details & cashback screens), and skips local-storage / signing * customer-facing state whether a wallet has Tangem Pay ([hasTangemPayInWallet]), KYC status, ACTIVE /
* enrollment; remaining server calls go to WireMock. * INACTIVE, balances ([getCustomerInfo]) is driven by the WireMock test scenario (authenticated with the
* synthetic tokens from [com.tangem.data.pay.store.MockAwareTangemPayStorage]) rather than hardcoded:
* - [hasTangemPayInWallet] is answered locally via [TangemPayMockControl] (WireMock does not mock the
* underlying checkCustomerWalletId endpoint) default false, opt-in true for Tangem Pay scenarios;
* - [getCustomerInfo] delegates to the real repo (WireMock), so KYC / customer-state scenarios take effect.
*/ */
@Singleton @Singleton
internal class MockAwareOnboardingRepository @Inject constructor( internal class MockAwareOnboardingRepository @Inject constructor(
@ -55,10 +55,11 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.produceInitialData(userWalletId) real.produceInitialData(userWalletId)
} }
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> { // Delegates to WireMock (via the real repo + synthetic storage tokens) so the customer state — KYC status,
if (isMockMode) return MOCK_CUSTOMER_INFO.right() // ACTIVE/INACTIVE, balances — follows the test scenario instead of a hardcoded "always active" customer.
return real.getCustomerInfo(userWalletId) // Only reached when hasTangemPayInWallet is true, i.e. for wallets opted in via TangemPayMockControl.
} override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> =
real.getCustomerInfo(userWalletId)
override suspend fun getBankCredentials( override suspend fun getBankCredentials(
userWalletId: UserWalletId, userWalletId: UserWalletId,
@ -111,8 +112,12 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.storeVirtualAccountOrderId(userWalletId, vaOrderId) real.storeVirtualAccountOrderId(userWalletId, vaOrderId)
} }
// This is the gate that decides whether a wallet is treated as an existing Tangem Pay customer (and thus
// whether an active Payment account — and accounts mode — appears). WireMock does NOT mock the underlying
// checkCustomerWalletId endpoint, so we answer locally: default false (generic UI tests stay Payment-free),
// opt-in true via TangemPayMockControl for Tangem Pay scenarios. Never delegate to `real` here.
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> { override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
if (isMockMode) return true.right() if (isMockMode) return TangemPayMockControl.hasTangemPayInWallet.right()
return real.hasTangemPayInWallet(userWalletId) return real.hasTangemPayInWallet(userWalletId)
} }
@ -152,52 +157,5 @@ internal class MockAwareOnboardingRepository @Inject constructor(
private companion object { private companion object {
const val MOCK_ORDER_ID = "mock-order-id" const val MOCK_ORDER_ID = "mock-order-id"
const val MOCK_VA_ORDER_ID = "mock-va-order-id" const val MOCK_VA_ORDER_ID = "mock-va-order-id"
const val MOCK_CUSTOMER_ID = "mock-customer-id"
const val MOCK_CARD_ID = "mock-card-id"
const val MOCK_PRODUCT_INSTANCE_ID = "mock-product-instance-id"
const val MOCK_CUSTOMER_WALLET_ADDRESS = "0x0000000000000000000000000000000000000002"
const val MOCK_TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
const val MOCK_POLYGON_CHAIN_ID = 137L
val MOCK_CUSTOMER_INFO = CustomerInfo(
customerId = MOCK_CUSTOMER_ID,
productInstances = listOf(
CustomerInfo.ProductInstance(
id = MOCK_PRODUCT_INSTANCE_ID,
cardId = MOCK_CARD_ID,
frozenState = TangemPayCardFrozenState.Unfrozen,
displayName = null,
actualCardLimit = null,
adminCardLimit = null,
status = CustomerInfo.ProductInstance.Status.ACTIVE,
specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.CARD,
),
),
cards = listOf(
CustomerInfo.CardInfo(
cardId = MOCK_CARD_ID,
cardStatus = TangemPayCard.Status.ACTIVE,
lastFourDigits = "4242",
isPinSet = true,
images = emptyList(),
),
),
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("123.45"),
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = "usd-coin",
chainId = MOCK_POLYGON_CHAIN_ID,
depositAddress = MOCK_CUSTOMER_WALLET_ADDRESS,
tokenContractAddress = MOCK_TOKEN_CONTRACT_ADDRESS,
balance = BigDecimal("123.45"),
),
availableForWithdrawal = BigDecimal("123.45"),
tariffPlan = null,
)
} }
} }