From 1dc70cf918cd6ee028d12cff9005d1477932cbcb Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Jul 2026 09:07:11 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 14 ++++ .../tangem/scenarios/TangemPayScenarios.kt | 2 + .../tangempay/TangemPayOnboardingKycTest.kt | 8 ++ .../utils/WireMockRedirectInterceptor.kt | 69 +++++++++++++--- .../tangem/data/pay/TangemPayMockControl.kt | 30 +++++++ .../MockAwareOnboardingRepository.kt | 78 +++++-------------- 6 files changed, 130 insertions(+), 71 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayMockControl.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index fab9e10cfb..dd3f4dc1e5 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -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.WAIT_UNTIL_TIMEOUT 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.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -111,6 +112,9 @@ abstract class BaseTestCase : TestCase( additionalBeforeSection: () -> Unit = {}, additionalAfterSection: () -> Unit = {}, ) = 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) // Setup WireMock redirect for CI with local WireMock instances val wiremockUrl = InstrumentationRegistry.getArguments().getString(WIREMOCK_BASE_URL_ARG) @@ -180,6 +184,16 @@ abstract class BaseTestCase : TestCase( 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) * code where flakySafely is unavailable; in test bodies prefer flakySafely. diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/TangemPayScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/TangemPayScenarios.kt index 6372090d66..691ad65e6e 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/TangemPayScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/TangemPayScenarios.kt @@ -12,6 +12,8 @@ import com.tangem.screens.tangempay.* import io.qameta.allure.kotlin.Allure.step 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)") { openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12, accessCode = TANGEM_PAY_ACCESS_CODE) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayOnboardingKycTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayOnboardingKycTest.kt index f6f0abc42a..104f63c820 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayOnboardingKycTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayOnboardingKycTest.kt @@ -35,6 +35,8 @@ class TangemPayOnboardingKycTest : BaseTestCase() { val paeraCustomerState = "PaeraCustomer" setupHooks( + // Existing customer: opt into the Tangem Pay mock so the Payment account (tile) appears. + additionalBeforeSection = { markExistingTangemPayCustomer() }, additionalAfterSection = { resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) }, @@ -134,6 +136,9 @@ class TangemPayOnboardingKycTest : BaseTestCase() { val viewStatusText = getResourceString(CoreResR.string.tangempay_kyc_in_progress_notification_button) 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 = { resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO) @@ -187,6 +192,9 @@ class TangemPayOnboardingKycTest : BaseTestCase() { val goToSupportText = getResourceString(CoreResR.string.tangempay_go_to_support) 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 = { resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) resetWireMockScenarioState(TANGEM_PAY_KYC_STATUS_SCENARIO) diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt index 78d1278bcf..550b86f627 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt @@ -2,10 +2,18 @@ package com.tangem.datasource.utils import com.tangem.utils.logging.TangemLogger import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody /** * 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 { @@ -17,25 +25,64 @@ class WireMockRedirectInterceptor : Interceptor { val host = request.url.host val sanitizedOverride = override.trimEnd('/') - if (host == WIREMOCK_REMOTE_HOST) { - val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride) - TangemLogger.d("WireMockRedirect: $url -> $newUrl") - return chain.proceed(request.newBuilder().url(newUrl).build()) + val redirectedRequest = when { + host == WIREMOCK_REMOTE_HOST -> { + val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride) + TangemLogger.d("WireMockRedirect: $url -> $newUrl") + request.newBuilder().url(newUrl).build() + } + host in REDIRECTABLE_THIRD_PARTY_HOSTS -> { + val newUrl = url.replace("https://$host", "$sanitizedOverride/$host") + TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl") + request.newBuilder().url(newUrl).build() + } + else -> request } - if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) { - val newUrl = url.replace("https://$host", "$sanitizedOverride/$host") - TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl") - return chain.proceed(request.newBuilder().url(newUrl).build()) - } - - 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 { private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com" 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 * when [overriddenBaseUrl] is set. Each matched URL becomes `//`, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayMockControl.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayMockControl.kt new file mode 100644 index 0000000000..d156530919 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayMockControl.kt @@ -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 + } +} \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index 18f051e157..c59e5e74f4 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -3,28 +3,28 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.right 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.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager 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.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.visa.error.VisaApiError -import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton /** - * In MOCK env returns canned onboarding data so the Payment Account shows up as fully loaded on the main - * screen (the entry point to the TangemPay details & cashback screens), and skips local-storage / signing - * enrollment; remaining server calls go to WireMock. + * In MOCK env only skips the local-storage / NFC-signing enrollment steps (order ids, initial data). The + * customer-facing state — whether a wallet has Tangem Pay ([hasTangemPayInWallet]), KYC status, ACTIVE / + * 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 internal class MockAwareOnboardingRepository @Inject constructor( @@ -55,10 +55,11 @@ internal class MockAwareOnboardingRepository @Inject constructor( real.produceInitialData(userWalletId) } - override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either { - if (isMockMode) return MOCK_CUSTOMER_INFO.right() - return real.getCustomerInfo(userWalletId) - } + // Delegates to WireMock (via the real repo + synthetic storage tokens) so the customer state — KYC status, + // ACTIVE/INACTIVE, balances — follows the test scenario instead of a hardcoded "always active" customer. + // Only reached when hasTangemPayInWallet is true, i.e. for wallets opted in via TangemPayMockControl. + override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either = + real.getCustomerInfo(userWalletId) override suspend fun getBankCredentials( userWalletId: UserWalletId, @@ -111,8 +112,12 @@ internal class MockAwareOnboardingRepository @Inject constructor( 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 { - if (isMockMode) return true.right() + if (isMockMode) return TangemPayMockControl.hasTangemPayInWallet.right() return real.hasTangemPayInWallet(userWalletId) } @@ -152,52 +157,5 @@ internal class MockAwareOnboardingRepository @Inject constructor( private companion object { const val MOCK_ORDER_ID = "mock-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, - ) } } \ No newline at end of file