diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/TangemPayScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/TangemPayScenarios.kt index 6372090d66..55c9009c89 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() { + // 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) } 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/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index 18f051e157..504ac2e99a 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 @@ -7,24 +7,24 @@ 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] delegates to the real repo, so the "existing customer" gate follows the + * checkCustomerWalletId mock (the `tangem_pay_eligibility` scenario: `Started` → 404/NotPaeraCustomer → + * no Payment account, `PaeraCustomer` → 200 → Payment account); + * - [getCustomerInfo] delegates to the real repo (WireMock), so KYC / customer-state scenarios take effect. */ @Singleton internal class MockAwareOnboardingRepository @Inject constructor( @@ -55,10 +55,12 @@ 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. + // Note: this may be invoked even when `hasTangemPayInWallet` is false (e.g., onboarding/deeplink flows), + // so tests must provide the corresponding WireMock mappings. + override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either = + real.getCustomerInfo(userWalletId) override suspend fun getBankCredentials( userWalletId: UserWalletId, @@ -111,10 +113,12 @@ internal class MockAwareOnboardingRepository @Inject constructor( real.storeVirtualAccountOrderId(userWalletId, vaOrderId) } - override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either { - if (isMockMode) return true.right() - return real.hasTangemPayInWallet(userWalletId) - } + // The "existing Tangem Pay customer" gate (decides whether an active Payment account — and accounts mode — + // appears). Delegates to WireMock's checkCustomerWalletId via the real repo (static token, no signing), so it + // is driven by the `tangem_pay_eligibility` scenario: `Started` (default) → 404/NotPaeraCustomer → no account; + // `PaeraCustomer` → 200 → account. Generic UI tests never set the scenario, so they stay Payment-free. + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = + real.hasTangemPayInWallet(userWalletId) override suspend fun checkCustomerEligibility(): List = real.checkCustomerEligibility() @@ -152,52 +156,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