diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt index 6ff451dd0..f3f8fa672 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt @@ -9,9 +9,10 @@ import androidx.annotation.RestrictTo object APIClientError { // 0. An unknown error occurred. - fun unknownError(correlationId: String?) = PayPalSDKError( + fun unknownError(correlationId: String? = null, throwable: Throwable? = null) = PayPalSDKError( code = PayPalSDKErrorCode.UNKNOWN.ordinal, errorDescription = "An unknown error occurred. Contact developer.paypal.com/support.", + reason = throwable, correlationId = correlationId ) @@ -37,12 +38,6 @@ object APIClientError { correlationId = correlationId ) - // 4. There was an error constructing the URLRequest. - val invalidUrlRequest = PayPalSDKError( - code = PayPalSDKErrorCode.INVALID_URL_REQUEST.ordinal, - errorDescription = "An error occurred constructing an HTTP request. Contact developer.paypal.com/support." - ) - // 5. The server's response body returned an error message. fun serverResponseError(correlationId: String?) = PayPalSDKError( code = PayPalSDKErrorCode.SERVER_RESPONSE_ERROR.ordinal, @@ -58,28 +53,6 @@ object APIClientError { correlationId = correlationId ) - val payPalCheckoutError: (description: String) -> PayPalSDKError = { description -> - PayPalSDKError( - code = PayPalSDKErrorCode.CHECKOUT_ERROR.ordinal, - errorDescription = description - ) - } - - val payPalNativeCheckoutError: (description: String, reason: Exception) -> PayPalSDKError = - { description, reason -> - PayPalSDKError( - code = PayPalSDKErrorCode.NATIVE_CHECKOUT_ERROR.ordinal, - errorDescription = description, - reason = reason - ) - } - - fun clientIDNotFoundError(code: Int, correlationId: String?) = PayPalSDKError( - code = code, - errorDescription = "Error fetching clientId. Contact developer.paypal.com/support.", - correlationId = correlationId - ) - fun graphQLJSONParseError(correlationId: String?, reason: Exception): PayPalSDKError { val message = "An error occurred while parsing the GraphQL response JSON. Contact developer.paypal.com/support." diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt index 23b97fee3..00013f76a 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt @@ -6,4 +6,9 @@ import androidx.annotation.RestrictTo * @suppress */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -data class APIRequest(val path: String, val method: HttpMethod, val body: String? = null) +data class APIRequest( + val path: String, + val method: HttpMethod, + val body: String? = null, + val headers: Map? = null +) diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt index ea5717312..23fdb3106 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt @@ -15,5 +15,6 @@ enum class PayPalSDKErrorCode { SERVER_RESPONSE_ERROR, CHECKOUT_ERROR, NATIVE_CHECKOUT_ERROR, - GRAPHQL_JSON_INVALID_ERROR + GRAPHQL_JSON_INVALID_ERROR, + NO_ACCESS_TOKEN_ERROR, } diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt new file mode 100644 index 000000000..8a4fa8200 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt @@ -0,0 +1,65 @@ +package com.paypal.android.corepayments.api + +import androidx.annotation.RestrictTo +import com.paypal.android.corepayments.APIClientError +import com.paypal.android.corepayments.APIRequest +import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.HttpMethod +import com.paypal.android.corepayments.PayPalSDKError +import com.paypal.android.corepayments.PayPalSDKErrorCode +import com.paypal.android.corepayments.RestClient +import com.paypal.android.corepayments.base64encoded +import org.json.JSONObject + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class AuthenticationSecureTokenServiceAPI( + private val coreConfig: CoreConfig, + private val restClient: RestClient = RestClient(configuration = coreConfig), +) { + suspend fun createLowScopedAccessToken(): CreateLowScopedAccessTokenResult { + val requestBody = "grant_type=client_credentials&response_type=token" + + val headers = mutableMapOf( + "Authorization" to "Basic ${coreConfig.clientId.base64encoded()}:", + "Content-Type" to "application/x-www-form-urlencoded" + ) + + val apiRequest = APIRequest( + path = "v1/oauth2/token", + method = HttpMethod.POST, + body = requestBody, + headers = headers + ) + + return runCatching { + val httpResponse = restClient.send(apiRequest) + val correlationId = httpResponse.headers["paypal-debug-id"] + if (httpResponse.isSuccessful && httpResponse.body != null) { + val jsonObject = JSONObject(httpResponse.body) + val token = jsonObject.optString("access_token") + if (token.isNotEmpty()) { + CreateLowScopedAccessTokenResult.Success(token) + } else { + val error = PayPalSDKError( + code = PayPalSDKErrorCode.NO_ACCESS_TOKEN_ERROR.ordinal, + errorDescription = "Missing access_token in response", + correlationId = correlationId + ) + CreateLowScopedAccessTokenResult.Failure(error) + } + } else { + val error = httpResponse.run { + PayPalSDKError( + code = status, + errorDescription = body ?: "Unknown error", + correlationId = correlationId + ) + } + CreateLowScopedAccessTokenResult.Failure(error) + } + }.getOrElse { throwable -> + val error = APIClientError.unknownError(throwable = throwable) + CreateLowScopedAccessTokenResult.Failure(error) + } + } +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt new file mode 100644 index 000000000..6edc99438 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt @@ -0,0 +1,21 @@ +package com.paypal.android.corepayments.api + +import androidx.annotation.RestrictTo +import com.paypal.android.corepayments.PayPalSDKError + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +sealed class CreateLowScopedAccessTokenResult { + /** + * The request to get client token was successful. + * + * @property token the access token from the response + */ + data class Success(val token: String) : CreateLowScopedAccessTokenResult() + + /** + * There was an error with the request to get client token. + * + * @property error the error that occurred + */ + data class Failure(val error: PayPalSDKError) : CreateLowScopedAccessTokenResult() +} diff --git a/CorePayments/src/test/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPIUnitTest.kt b/CorePayments/src/test/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPIUnitTest.kt new file mode 100644 index 000000000..43c07db6b --- /dev/null +++ b/CorePayments/src/test/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPIUnitTest.kt @@ -0,0 +1,230 @@ +package com.paypal.android.corepayments.api + +import com.paypal.android.corepayments.APIRequest +import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.Environment +import com.paypal.android.corepayments.HttpMethod +import com.paypal.android.corepayments.HttpResponse +import com.paypal.android.corepayments.RestClient +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class AuthenticationSecureTokenServiceAPIUnitTest { + + private lateinit var restClient: RestClient + private lateinit var coreConfig: CoreConfig + private lateinit var sut: AuthenticationSecureTokenServiceAPI + + @Before + fun beforeEach() { + coreConfig = CoreConfig("test-client-id", Environment.SANDBOX) + restClient = mockk(relaxed = true) + sut = AuthenticationSecureTokenServiceAPI(coreConfig, restClient) + } + + @Test + fun `createLowScopedAccessToken() makes correct API request with proper headers and body`() = + runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = """{"access_token": "test-token", "token_type": "Bearer", "expires_in": 3600}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + val requestSlot = slot() + + // When + val result = sut.createLowScopedAccessToken() + + // Then + coVerify { restClient.send(capture(requestSlot)) } + + val capturedRequest = requestSlot.captured + assertEquals("v1/oauth2/token", capturedRequest.path) + assertEquals(HttpMethod.POST, capturedRequest.method) + assertEquals("grant_type=client_credentials&response_type=token", capturedRequest.body) + + // Verify headers + val headers = capturedRequest.headers!! + assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) + assert(headers["Authorization"]!!.startsWith("Basic ")) + + assertTrue(result is CreateLowScopedAccessTokenResult.Success) + assertEquals("test-token", (result as CreateLowScopedAccessTokenResult.Success).token) + } + + @Test + fun `createLowScopedAccessToken() returns access token from successful response`() = runTest { + // Given + val expectedToken = "test-access-token-12345" + val successResponse = HttpResponse( + status = 200, + body = """{"access_token": "$expectedToken", "token_type": "Bearer", "expires_in": 3600}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Success) + assertEquals(expectedToken, (result as CreateLowScopedAccessTokenResult.Success).token) + } + + @Test + fun `createLowScopedAccessToken() returns failure when response is not successful`() = runTest { + // Given + val errorResponse = HttpResponse( + status = 401, + body = """{"error": "invalid_client", "error_description": "Client authentication failed"}""" + ) + coEvery { restClient.send(any()) } returns errorResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + } + + @Test + fun `createLowScopedAccessToken() returns failure when response body is null`() = runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = null + ) + coEvery { restClient.send(any()) } returns successResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + } + + @Test + fun `createLowScopedAccessToken() returns failure when response is not valid JSON`() = runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = "invalid-json-response" + ) + coEvery { restClient.send(any()) } returns successResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + // Expected JSONException wrapped in PayPalSDKError + } + + @Test + fun `createLowScopedAccessToken() returns failure when access_token field is missing from successful response`() = + runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = """{"token_type": "Bearer", "expires_in": 3600}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + val error = (result as CreateLowScopedAccessTokenResult.Failure).error + assertTrue(error.errorDescription.contains("Missing access_token in response")) + } + + @Test + fun `createLowScopedAccessToken() handles empty response body with proper error message`() = + runTest { + // Given + val errorResponse = HttpResponse( + status = 500, + body = "" + ) + coEvery { restClient.send(any()) } returns errorResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + val errorMessage = + (result as CreateLowScopedAccessTokenResult.Failure).error.errorDescription + // The error description will be the empty body from the HTTP response + assertEquals("", errorMessage) + } + + @Test + fun `createLowScopedAccessToken() includes error message in exception when available`() = + runTest { + // Given + val errorMessage = "Server temporarily unavailable" + val errorResponse = HttpResponse( + status = 503, + body = errorMessage + ) + coEvery { restClient.send(any()) } returns errorResponse + + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + val description = + (result as CreateLowScopedAccessTokenResult.Failure).error.errorDescription + // The error description will be the error message from the HTTP response body + assertEquals(errorMessage, description) + } + + @Test + fun `createLowScopedAccessToken() creates proper Basic Auth header from client ID`() = runTest { + // Given + val clientId = "test-client-123" + val configWithCustomClientId = CoreConfig(clientId, Environment.LIVE) + val sutWithCustomConfig = + AuthenticationSecureTokenServiceAPI(configWithCustomClientId, restClient) + + val successResponse = HttpResponse( + status = 200, + body = """{"access_token": "token", "token_type": "Bearer"}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + val requestSlot = slot() + + // When + sutWithCustomConfig.createLowScopedAccessToken() + + // Then + coVerify { restClient.send(capture(requestSlot)) } + + val authHeader = requestSlot.captured.headers!!["Authorization"]!! + assert(authHeader.startsWith("Basic ")) + + // Decode and verify the Basic auth contains the client ID + val encodedCredentials = authHeader.substring("Basic ".length) + val decodedClientId = + android.util.Base64.decode(encodedCredentials, android.util.Base64.DEFAULT) + .decodeToString() + assertEquals(clientId, decodedClientId) + } +} diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt new file mode 100644 index 000000000..89e42edec --- /dev/null +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt @@ -0,0 +1,13 @@ +package com.paypal.android.paypalwebpayments + +import androidx.annotation.MainThread + +fun interface PayPalWebCheckoutCallback { + /** + * Called when the PayPal web checkout operation completes. + * + * @param result [PayPalPresentAuthChallengeResult] result with details + */ + @MainThread + fun onPayPalWebCheckoutResult(result: PayPalPresentAuthChallengeResult) +} diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt index adaa99798..dfcadf817 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt @@ -4,10 +4,17 @@ import android.content.Context import android.content.Intent import androidx.activity.ComponentActivity import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.RestClient import com.paypal.android.corepayments.analytics.AnalyticsService +import com.paypal.android.corepayments.api.AuthenticationSecureTokenServiceAPI +import com.paypal.android.corepayments.api.CreateLowScopedAccessTokenResult import com.paypal.android.paypalwebpayments.analytics.CheckoutEvent import com.paypal.android.paypalwebpayments.analytics.PayPalWebAnalytics import com.paypal.android.paypalwebpayments.analytics.VaultEvent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch // NEXT MAJOR VERSION: consider renaming this module to PayPalWebClient since // it now offers both checkout and vaulting @@ -17,7 +24,9 @@ import com.paypal.android.paypalwebpayments.analytics.VaultEvent */ class PayPalWebCheckoutClient internal constructor( private val analytics: PayPalWebAnalytics, - private val payPalWebLauncher: PayPalWebLauncher + private val payPalWebLauncher: PayPalWebLauncher, + private val authenticationSecureTokenServiceAPI: AuthenticationSecureTokenServiceAPI, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) { // for analytics tracking @@ -34,6 +43,7 @@ class PayPalWebCheckoutClient internal constructor( constructor(context: Context, configuration: CoreConfig, urlScheme: String) : this( PayPalWebAnalytics(AnalyticsService(context.applicationContext, configuration)), PayPalWebLauncher(urlScheme, configuration), + AuthenticationSecureTokenServiceAPI(configuration, RestClient(configuration)) ) /** @@ -41,24 +51,58 @@ class PayPalWebCheckoutClient internal constructor( * * @param request [PayPalWebCheckoutRequest] for requesting an order approval */ - fun start( + suspend fun start( activity: ComponentActivity, request: PayPalWebCheckoutRequest ): PayPalPresentAuthChallengeResult { checkoutOrderId = request.orderId analytics.notify(CheckoutEvent.STARTED, checkoutOrderId) - val result = payPalWebLauncher.launchPayPalWebCheckout(activity, request) - when (result) { - is PayPalPresentAuthChallengeResult.Success -> analytics.notify( - CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, - checkoutOrderId - ) - - is PayPalPresentAuthChallengeResult.Failure -> + val tokenResult = authenticationSecureTokenServiceAPI.createLowScopedAccessToken() + return when (tokenResult) { + is CreateLowScopedAccessTokenResult.Failure -> { analytics.notify(CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, checkoutOrderId) + PayPalPresentAuthChallengeResult.Failure(tokenResult.error) + } + + is CreateLowScopedAccessTokenResult.Success -> { + // todo: token will be used while fetching app switch eligibility + val result = payPalWebLauncher.launchPayPalWebCheckout(activity, request) + when (result) { + is PayPalPresentAuthChallengeResult.Success -> analytics.notify( + CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, + checkoutOrderId + ) + + is PayPalPresentAuthChallengeResult.Failure -> + analytics.notify( + CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, + checkoutOrderId + ) + } + result + } + } + } + + /** + * Confirm PayPal payment source for an order with callback. + * Network operations are handled automatically by the Http layer. + * + * @param activity the ComponentActivity to launch the auth challenge from + * @param request [PayPalWebCheckoutRequest] for requesting an order approval + * @param callback callback to receive the result + */ + fun start( + activity: ComponentActivity, + request: PayPalWebCheckoutRequest, + callback: PayPalWebCheckoutCallback + ) { + CoroutineScope(mainDispatcher).launch { + callback.onPayPalWebCheckoutResult( + start(activity, request) + ) } - return result } /** diff --git a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt index 6157f79a9..6ebf43fac 100644 --- a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt +++ b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt @@ -3,13 +3,18 @@ package com.paypal.android.paypalwebpayments import android.content.Intent import androidx.fragment.app.FragmentActivity import com.paypal.android.corepayments.PayPalSDKError +import com.paypal.android.corepayments.api.AuthenticationSecureTokenServiceAPI +import com.paypal.android.corepayments.api.CreateLowScopedAccessTokenResult import com.paypal.android.paypalwebpayments.analytics.PayPalWebAnalytics +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify import junit.framework.TestCase.assertSame import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -21,6 +26,7 @@ class PayPalWebCheckoutClientUnitTest { private val activity: FragmentActivity = mockk(relaxed = true) private val analytics = mockk(relaxed = true) + private lateinit var authenticationSecureTokenServiceAPI: AuthenticationSecureTokenServiceAPI private val intent = Intent() @@ -30,21 +36,32 @@ class PayPalWebCheckoutClientUnitTest { @Before fun beforeEach() { payPalWebLauncher = mockk(relaxed = true) - sut = PayPalWebCheckoutClient(analytics, payPalWebLauncher) + authenticationSecureTokenServiceAPI = mockk(relaxed = true) + sut = PayPalWebCheckoutClient( + analytics, + payPalWebLauncher, + authenticationSecureTokenServiceAPI + ) + + // Mock successful token fetch by default + coEvery { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } returns + CreateLowScopedAccessTokenResult.Success("fake-access-token") } @Test - fun `start() launches PayPal web checkout`() { + fun `start() fetches client token and launches PayPal web checkout`() = runTest { val launchResult = PayPalPresentAuthChallengeResult.Success("auth state") every { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } returns launchResult val request = PayPalWebCheckoutRequest("fake-order-id") sut.start(activity, request) + + coVerify(exactly = 1) { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } verify(exactly = 1) { payPalWebLauncher.launchPayPalWebCheckout(activity, request) } } @Test - fun `start() notifies merchant of browser switch failure`() { + fun `start() notifies merchant of browser switch failure`() = runTest { val sdkError = PayPalSDKError(123, "fake error description") val launchResult = PayPalPresentAuthChallengeResult.Failure(sdkError) every { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } returns launchResult @@ -54,6 +71,33 @@ class PayPalWebCheckoutClientUnitTest { assertSame(launchResult, result) } + @Test + fun `start() returns failure when FetchClientToken fails`() = runTest { + val tokenError = PayPalSDKError(401, "Token fetch failed") + coEvery { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } returns + CreateLowScopedAccessTokenResult.Failure(tokenError) + + val request = PayPalWebCheckoutRequest("fake-order-id") + + val result = sut.start(activity, request) + assertTrue(result is PayPalPresentAuthChallengeResult.Failure) + assertSame(tokenError, (result as PayPalPresentAuthChallengeResult.Failure).error) + } + + @Test + fun `start() does not call launcher when token fetch fails`() = runTest { + val tokenError = PayPalSDKError(401, "Token fetch failed") + coEvery { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } returns + CreateLowScopedAccessTokenResult.Failure(tokenError) + + val request = PayPalWebCheckoutRequest("fake-order-id") + + val result = sut.start(activity, request) + assertTrue(result is PayPalPresentAuthChallengeResult.Failure) + + verify(exactly = 0) { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } + } + @Test fun `vault() launches PayPal web checkout`() { val launchResult = PayPalPresentAuthChallengeResult.Success("auth state")