diff --git a/CardPayments/src/main/java/com/paypal/android/cardpayments/CardError.kt b/CardPayments/src/main/java/com/paypal/android/cardpayments/CardError.kt index 196624840..33e98960f 100644 --- a/CardPayments/src/main/java/com/paypal/android/cardpayments/CardError.kt +++ b/CardPayments/src/main/java/com/paypal/android/cardpayments/CardError.kt @@ -2,6 +2,7 @@ package com.paypal.android.cardpayments import com.paypal.android.corepayments.PayPalSDKError import com.paypal.android.corepayments.graphql.GraphQLError +import kotlinx.serialization.InternalSerializationApi internal object CardError { @@ -29,6 +30,7 @@ internal object CardError { errorDescription = cause.message ?: "Unable to Browser Switch" ) + @OptIn(InternalSerializationApi::class) fun updateSetupTokenResponseBodyMissing(errors: List?, correlationId: String?) = PayPalSDKError( 0, "Error updating setup token: $errors", diff --git a/CardPayments/src/main/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPI.kt b/CardPayments/src/main/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPI.kt index d5af06cd5..7d9785c52 100644 --- a/CardPayments/src/main/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPI.kt +++ b/CardPayments/src/main/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPI.kt @@ -2,15 +2,19 @@ package com.paypal.android.cardpayments import android.content.Context import androidx.annotation.RawRes +import com.paypal.android.cardpayments.api.UpdateSetupTokenResponse +import com.paypal.android.cardpayments.api.UpdateSetupTokenVariables +import com.paypal.android.cardpayments.api.VaultBillingAddress +import com.paypal.android.cardpayments.api.VaultCard +import com.paypal.android.cardpayments.api.VaultPaymentSource import com.paypal.android.corepayments.CoreConfig import com.paypal.android.corepayments.LoadRawResourceResult import com.paypal.android.corepayments.PayPalSDKError import com.paypal.android.corepayments.ResourceLoader import com.paypal.android.corepayments.graphql.GraphQLClient +import com.paypal.android.corepayments.graphql.GraphQLRequest import com.paypal.android.corepayments.graphql.GraphQLResult -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject +import kotlinx.serialization.InternalSerializationApi internal class DataVaultPaymentMethodTokensAPI internal constructor( private val coreConfig: CoreConfig, @@ -36,6 +40,7 @@ internal class DataVaultPaymentMethodTokensAPI internal constructor( } } + @OptIn(InternalSerializationApi::class) private suspend fun sendUpdateSetupTokenGraphQLRequest( query: String, setupTokenId: String, @@ -45,47 +50,48 @@ internal class DataVaultPaymentMethodTokensAPI internal constructor( val cardNumber = card.number.replace("\\s".toRegex(), "") val cardExpiry = "${card.expirationYear}-${card.expirationMonth}" - val cardJSON = JSONObject() - .put("number", cardNumber) - .put("expiry", cardExpiry) - - card.cardholderName?.let { cardJSON.put("name", it) } - cardJSON.put("securityCode", card.securityCode) - - card.billingAddress?.apply { - val billingAddressJSON = JSONObject() - .put("addressLine1", streetAddress) - .put("addressLine2", extendedAddress) - .put("adminArea1", region) - .put("adminArea2", locality) - .put("postalCode", postalCode) - .put("countryCode", countryCode) - cardJSON.put("billingAddress", billingAddressJSON) + val vaultBillingAddress = card.billingAddress?.let { + VaultBillingAddress( + addressLine1 = it.streetAddress, + addressLine2 = it.extendedAddress, + adminArea1 = it.region, + adminArea2 = it.locality, + postalCode = it.postalCode, + countryCode = it.countryCode + ) } - val paymentSourceJSON = JSONObject() - paymentSourceJSON.put("card", cardJSON) + val vaultCard = VaultCard( + number = cardNumber, + expiry = cardExpiry, + name = card.cardholderName, + securityCode = card.securityCode, + billingAddress = vaultBillingAddress + ) + + val paymentSource = VaultPaymentSource(card = vaultCard) - val variables = JSONObject() - .put("clientId", coreConfig.clientId) - .put("vaultSetupToken", setupTokenId) - .put("paymentSource", paymentSourceJSON) + val variables = UpdateSetupTokenVariables( + clientId = coreConfig.clientId, + vaultSetupToken = setupTokenId, + paymentSource = paymentSource + ) - val graphQLRequest = JSONObject() - .put("query", query) - .put("variables", variables) + val graphQLRequest = GraphQLRequest(query, variables, "UpdateVaultSetupToken") val graphQLResponse = - graphQLClient.send(graphQLRequest, queryName = "UpdateVaultSetupToken") + graphQLClient.send(graphQLRequest) return when (graphQLResponse) { is GraphQLResult.Success -> { - val responseJSON = graphQLResponse.data - if (responseJSON == null) { - val error = graphQLResponse.run { - CardError.updateSetupTokenResponseBodyMissing(errors, correlationId) - } + val response = graphQLResponse.response + val responseData = response.data + if (responseData == null) { + val error = CardError.updateSetupTokenResponseBodyMissing( + response.errors, + graphQLResponse.correlationId + ) UpdateSetupTokenResult.Failure(error) } else { - parseSuccessfulUpdateSuccessJSON(responseJSON, graphQLResponse.correlationId) + parseSuccessfulUpdateSuccess(responseData, graphQLResponse.correlationId) } } @@ -95,38 +101,28 @@ internal class DataVaultPaymentMethodTokensAPI internal constructor( } } - private fun parseSuccessfulUpdateSuccessJSON( - responseBody: JSONObject, + @OptIn(InternalSerializationApi::class) + private fun parseSuccessfulUpdateSuccess( + responseBody: UpdateSetupTokenResponse, correlationId: String? ): UpdateSetupTokenResult { - return try { - val setupTokenJSON = responseBody.getJSONObject("updateVaultSetupToken") - val status = setupTokenJSON.getString("status") + return runCatching { + val setupToken = responseBody.updateVaultSetupToken + val status = setupToken.status val approveHref = if (status == "PAYER_ACTION_REQUIRED") { - findLinkHref(setupTokenJSON, "approve") + setupToken.links?.find { it.rel == "approve" }?.href } else { null } UpdateSetupTokenResult.Success( - setupTokenId = setupTokenJSON.getString("id"), + setupTokenId = setupToken.id, status = status, approveHref = approveHref ) - } catch (jsonError: JSONException) { - val message = "Update Setup Token Failed: GraphQL JSON body was invalid." - val error = PayPalSDKError(0, message, correlationId, reason = jsonError) - UpdateSetupTokenResult.Failure(error) - } - } - - private fun findLinkHref(responseJSON: JSONObject, rel: String): String? { - val linksJSON = responseJSON.optJSONArray("links") ?: JSONArray() - for (i in 0 until linksJSON.length()) { - val link = linksJSON.getJSONObject(i) - if (link.optString("rel") == rel) { - return link.optString("href") - } + }.getOrElse { error -> + val message = "Update Setup Token Failed: Response parsing failed." + val sdkError = PayPalSDKError(0, message, correlationId, reason = error) + UpdateSetupTokenResult.Failure(sdkError) } - return null } } diff --git a/CardPayments/src/main/java/com/paypal/android/cardpayments/api/UpdateSetupTokenRequest.kt b/CardPayments/src/main/java/com/paypal/android/cardpayments/api/UpdateSetupTokenRequest.kt new file mode 100644 index 000000000..ebee9f885 --- /dev/null +++ b/CardPayments/src/main/java/com/paypal/android/cardpayments/api/UpdateSetupTokenRequest.kt @@ -0,0 +1,79 @@ +package com.paypal.android.cardpayments.api + +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Data classes for Kotlin serialization of update vault setup token GraphQL request + */ +@InternalSerializationApi +@Serializable +internal data class UpdateSetupTokenVariables( + @SerialName("clientId") + val clientId: String, + @SerialName("vaultSetupToken") + val vaultSetupToken: String, + @SerialName("paymentSource") + val paymentSource: VaultPaymentSource +) + +@InternalSerializationApi +@Serializable +internal data class VaultPaymentSource( + val card: VaultCard +) + +@InternalSerializationApi +@Serializable +internal data class VaultCard( + val number: String, + val expiry: String, + val name: String? = null, + @SerialName("securityCode") + val securityCode: String, + @SerialName("billingAddress") + val billingAddress: VaultBillingAddress? = null +) + +@InternalSerializationApi +@Serializable +internal data class VaultBillingAddress( + @SerialName("addressLine1") + val addressLine1: String? = null, + @SerialName("addressLine2") + val addressLine2: String? = null, + @SerialName("adminArea1") + val adminArea1: String? = null, + @SerialName("adminArea2") + val adminArea2: String? = null, + @SerialName("postalCode") + val postalCode: String? = null, + @SerialName("countryCode") + val countryCode: String +) + +/** + * Data classes for update vault setup token GraphQL response + */ +@InternalSerializationApi +@Serializable +internal data class UpdateSetupTokenResponse( + @SerialName("updateVaultSetupToken") + val updateVaultSetupToken: SetupTokenData +) + +@InternalSerializationApi +@Serializable +internal data class SetupTokenData( + val id: String, + val status: String, + val links: List? = null +) + +@InternalSerializationApi +@Serializable +internal data class LinkData( + val rel: String, + val href: String +) diff --git a/CardPayments/src/test/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPIUnitTest.kt b/CardPayments/src/test/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPIUnitTest.kt index d43e482e5..7edce15b5 100644 --- a/CardPayments/src/test/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPIUnitTest.kt +++ b/CardPayments/src/test/java/com/paypal/android/cardpayments/DataVaultPaymentMethodTokensAPIUnitTest.kt @@ -2,12 +2,19 @@ package com.paypal.android.cardpayments import android.app.Application import androidx.test.core.app.ApplicationProvider +import com.paypal.android.cardpayments.api.LinkData +import com.paypal.android.cardpayments.api.SetupTokenData +import com.paypal.android.cardpayments.api.UpdateSetupTokenResponse +import com.paypal.android.cardpayments.api.UpdateSetupTokenVariables import com.paypal.android.corepayments.Address import com.paypal.android.corepayments.CoreConfig import com.paypal.android.corepayments.Environment import com.paypal.android.corepayments.LoadRawResourceResult +import com.paypal.android.corepayments.PayPalSDKError import com.paypal.android.corepayments.ResourceLoader import com.paypal.android.corepayments.graphql.GraphQLClient +import com.paypal.android.corepayments.graphql.GraphQLRequest +import com.paypal.android.corepayments.graphql.GraphQLResponse import com.paypal.android.corepayments.graphql.GraphQLResult import io.mockk.coEvery import io.mockk.coVerify @@ -15,17 +22,16 @@ import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import org.json.JSONException -import org.json.JSONObject +import kotlinx.serialization.InternalSerializationApi import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.skyscreamer.jsonassert.JSONAssert +@OptIn(InternalSerializationApi::class) @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) class DataVaultPaymentMethodTokensAPIUnitTest { @@ -57,35 +63,33 @@ class DataVaultPaymentMethodTokensAPIUnitTest { sut = DataVaultPaymentMethodTokensAPI(coreConfig, context, graphQLClient, resourceLoader) sut.updateSetupToken("fake-setup-token-id", card) - val requestBodySlot = slot() - coVerify { graphQLClient.send(capture(requestBodySlot), "UpdateVaultSetupToken") } - val actualRequestBody = requestBodySlot.captured + val requestBodySlot = slot>() + coVerify { + graphQLClient.send( + capture(requestBodySlot) + ) + } + val request = requestBodySlot.captured val expectedQuery = resourceLoader.loadRawResource( context, R.raw.graphql_query_update_setup_token ) as LoadRawResourceResult.Success - // language=JSON - val expectedRequestBody = """ - { - "query": "${expectedQuery.value}", - "variables": { - "clientId": "fake-client-id", - "vaultSetupToken": "fake-setup-token-id", - "paymentSource": { - "card": { - "number": "4111111111111111", - "expiry": "24-01", - "name": "Jane Doe", - "securityCode": "123" - } - } - } - } - """ + assertEquals(expectedQuery.value, request.query) + + // Verify variables + val variables = request.variables!! + assertEquals("fake-client-id", variables.clientId) + assertEquals("fake-setup-token-id", variables.vaultSetupToken) - JSONAssert.assertEquals(JSONObject(expectedRequestBody), actualRequestBody, true) + // Verify card details + val card = variables.paymentSource.card + assertEquals("4111111111111111", card.number) + assertEquals("24-01", card.expiry) + assertEquals("Jane Doe", card.name) + assertEquals("123", card.securityCode) + assertNull(card.billingAddress) } @Test @@ -102,62 +106,63 @@ class DataVaultPaymentMethodTokensAPIUnitTest { sut = DataVaultPaymentMethodTokensAPI(coreConfig, context, graphQLClient, resourceLoader) sut.updateSetupToken("fake-setup-token-id", card) - val requestBodySlot = slot() - coVerify { graphQLClient.send(capture(requestBodySlot), "UpdateVaultSetupToken") } - val actualRequestBody = requestBodySlot.captured + val requestBodySlot = slot>() + coVerify { + graphQLClient.send( + capture(requestBodySlot) + ) + } + val request = requestBodySlot.captured val expectedQuery = resourceLoader.loadRawResource( context, R.raw.graphql_query_update_setup_token ) as LoadRawResourceResult.Success - // language=JSON - val expectedRequestBody = """ - { - "query": "${expectedQuery.value}", - "variables": { - "clientId": "fake-client-id", - "vaultSetupToken": "fake-setup-token-id", - "paymentSource": { - "card": { - "number": "4111111111111111", - "expiry": "24-01", - "name": "Jane Doe", - "securityCode": "123", - "billingAddress": { - "addressLine1": "2211 N 1st St.", - "addressLine2": "Apt. 1A", - "adminArea1": "CA", - "adminArea2": "San Jose", - "postalCode": "95131", - "countryCode": "US" - } - } - } - } - } - """ - - JSONAssert.assertEquals(JSONObject(expectedRequestBody), actualRequestBody, true) + assertEquals(expectedQuery.value, request.query) + + // Verify variables + val variables = request.variables!! + assertEquals("fake-client-id", variables.clientId) + assertEquals("fake-setup-token-id", variables.vaultSetupToken) + + // Verify card details + val card = variables.paymentSource.card + assertEquals(card, variables.paymentSource.card) + assertEquals("4111111111111111", card.number) + assertEquals("24-01", card.expiry) + assertEquals("Jane Doe", card.name) + assertEquals("123", card.securityCode) + + // Verify billing address + val billingAddress = card.billingAddress + assertNotNull(billingAddress) + assertEquals("2211 N 1st St.", billingAddress?.addressLine1) + assertEquals("Apt. 1A", billingAddress?.addressLine2) + assertEquals("CA", billingAddress?.adminArea1) + assertEquals("San Jose", billingAddress?.adminArea2) + assertEquals("95131", billingAddress?.postalCode) + assertEquals("US", billingAddress?.countryCode) } @Test fun updateSetupToken_returnsStatusApprovedVaultResult() = runTest { - // language=JSON - val json = """ - { - "updateVaultSetupToken": { - "id": "fake-setup-token-id-from-result", - "status": "APPROVED", - "links": [ - { "rel": "self", "href": "https://fake.com/self/url" }, - { "rel": "confirm", "href": "https://fake.com/confirm/url" } - ] - } - } - """.trimIndent() - val graphQLResult = GraphQLResult.Success(JSONObject(json)) - coEvery { graphQLClient.send(any(), "UpdateVaultSetupToken") } returns graphQLResult + val setupTokenData = SetupTokenData( + id = "fake-setup-token-id-from-result", + status = "APPROVED", + links = listOf( + LinkData(rel = "self", href = "https://fake.com/self/url"), + LinkData(rel = "confirm", href = "https://fake.com/confirm/url") + ) + ) + val responseData = UpdateSetupTokenResponse(updateVaultSetupToken = setupTokenData) + val graphQLResponse = GraphQLResponse(responseData) + val graphQLResult = GraphQLResult.Success(graphQLResponse) + coEvery { + graphQLClient.send( + any() + ) + } returns graphQLResult sut = DataVaultPaymentMethodTokensAPI(coreConfig, context, graphQLClient, resourceLoader) val result = sut.updateSetupToken("fake-setup-token-id", card) @@ -170,21 +175,22 @@ class DataVaultPaymentMethodTokensAPIUnitTest { @Test fun updateSetupToken_returnsVaultResultWithPayerActionURL() = runTest { - // language=JSON - val json = """ - { - "updateVaultSetupToken": { - "id": "fake-setup-token-id-from-result", - "status": "PAYER_ACTION_REQUIRED", - "links": [ - { "rel": "self", "href": "https://fake.com/self/url" }, - { "rel": "approve", "href": "https://fake.com/approval/url" } - ] - } - } - """.trimIndent() - val graphQLResult = GraphQLResult.Success(JSONObject(json)) - coEvery { graphQLClient.send(any(), "UpdateVaultSetupToken") } returns graphQLResult + val setupTokenData = SetupTokenData( + id = "fake-setup-token-id-from-result", + status = "PAYER_ACTION_REQUIRED", + links = listOf( + LinkData(rel = "self", href = "https://fake.com/self/url"), + LinkData(rel = "approve", href = "https://fake.com/approval/url") + ) + ) + val responseData = UpdateSetupTokenResponse(updateVaultSetupToken = setupTokenData) + val graphQLResponse = GraphQLResponse(responseData) + val graphQLResult = GraphQLResult.Success(graphQLResponse) + coEvery { + graphQLClient.send( + any() + ) + } returns graphQLResult sut = DataVaultPaymentMethodTokensAPI(coreConfig, context, graphQLClient, resourceLoader) val result = sut.updateSetupToken("fake-setup-token-id", card) @@ -197,43 +203,41 @@ class DataVaultPaymentMethodTokensAPIUnitTest { @Test fun updateSetupToken_returnsFailureWhenUpdateVaultSetupTokenFieldIsMissing() = runTest { - // language=JSON - val emptyJSON = """{}""".trimIndent() - val graphQLResult = - GraphQLResult.Success(JSONObject(emptyJSON), correlationId = "fake-correlation-id") - coEvery { graphQLClient.send(any(), "UpdateVaultSetupToken") } returns graphQLResult + val graphQLResult = GraphQLResult.Failure( + PayPalSDKError(0, "Missing required field", "fake-correlation-id") + ) + coEvery { + graphQLClient.send( + any() + ) + } returns graphQLResult sut = DataVaultPaymentMethodTokensAPI(coreConfig, context, graphQLClient, resourceLoader) val result = sut.updateSetupToken("fake-setup-token-id", card) as UpdateSetupTokenResult.Failure - val expectedMessage = "Update Setup Token Failed: GraphQL JSON body was invalid." + val expectedMessage = "Missing required field" assertEquals(expectedMessage, result.error.errorDescription) assertEquals("fake-correlation-id", result.error.correlationId) - assertTrue(result.error.cause is JSONException) } @Test fun updateSetupToken_returnsFailureWhenStatusFieldIsMissing() = runTest { - // language=JSON - val json = """ - { - "updateVaultSetupToken": { - "id": "fake-setup-token-id-from-result" - } - } - """.trimIndent() - val graphQLResult = - GraphQLResult.Success(JSONObject(json), correlationId = "fake-correlation-id") - coEvery { graphQLClient.send(any(), "UpdateVaultSetupToken") } returns graphQLResult + val graphQLResult = GraphQLResult.Failure( + PayPalSDKError(0, "Serialization failed", "fake-correlation-id") + ) + coEvery { + graphQLClient.send( + any() + ) + } returns graphQLResult sut = DataVaultPaymentMethodTokensAPI(coreConfig, context, graphQLClient, resourceLoader) val result = sut.updateSetupToken("fake-setup-token-id", card) as UpdateSetupTokenResult.Failure - val expectedMessage = "Update Setup Token Failed: GraphQL JSON body was invalid." + val expectedMessage = "Serialization failed" assertEquals(expectedMessage, result.error.errorDescription) assertEquals("fake-correlation-id", result.error.correlationId) - assertTrue(result.error.cause is JSONException) } } diff --git a/CorePayments/api/CorePayments.api b/CorePayments/api/CorePayments.api index e2225fde8..c9531f543 100644 --- a/CorePayments/api/CorePayments.api +++ b/CorePayments/api/CorePayments.api @@ -2,14 +2,16 @@ public final class com/paypal/android/corepayments/APIClientError { public static final field INSTANCE Lcom/paypal/android/corepayments/APIClientError; public final fun clientIDNotFoundError (ILjava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun dataParsingError (Ljava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; + public static synthetic fun dataParsingError$default (Lcom/paypal/android/corepayments/APIClientError;Ljava/lang/String;ILjava/lang/Object;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun getInvalidUrlRequest ()Lcom/paypal/android/corepayments/PayPalSDKError; public final fun getPayPalCheckoutError ()Lkotlin/jvm/functions/Function1; public final fun getPayPalNativeCheckoutError ()Lkotlin/jvm/functions/Function2; - public final fun graphQLJSONParseError (Ljava/lang/String;Ljava/lang/Exception;)Lcom/paypal/android/corepayments/PayPalSDKError; + public final fun graphQLJSONParseError (Ljava/lang/String;Ljava/lang/Throwable;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun httpURLConnectionError (ILjava/lang/String;Ljava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun noResponseData (Ljava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun serverResponseError (Ljava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun unknownError (Ljava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; + public static synthetic fun unknownError$default (Lcom/paypal/android/corepayments/APIClientError;Ljava/lang/String;ILjava/lang/Object;)Lcom/paypal/android/corepayments/PayPalSDKError; public final fun unknownHost (Ljava/lang/String;)Lcom/paypal/android/corepayments/PayPalSDKError; } @@ -232,14 +234,14 @@ public final class com/paypal/android/corepayments/graphql/GraphQLClient { public static final field Companion Lcom/paypal/android/corepayments/graphql/GraphQLClient$Companion; public static final field PAYPAL_DEBUG_ID Ljava/lang/String; public fun (Lcom/paypal/android/corepayments/CoreConfig;)V - public final fun send (Lorg/json/JSONObject;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun send$default (Lcom/paypal/android/corepayments/graphql/GraphQLClient;Lorg/json/JSONObject;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun sendInternal (Lcom/paypal/android/corepayments/graphql/GraphQLRequest;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class com/paypal/android/corepayments/graphql/GraphQLClient$Companion { } public final class com/paypal/android/corepayments/graphql/GraphQLError { + public static final field Companion Lcom/paypal/android/corepayments/graphql/GraphQLError$Companion; public fun (Ljava/lang/String;Ljava/util/List;)V public synthetic fun (Ljava/lang/String;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; @@ -253,7 +255,23 @@ public final class com/paypal/android/corepayments/graphql/GraphQLError { public fun toString ()Ljava/lang/String; } +public final class com/paypal/android/corepayments/graphql/GraphQLError$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/paypal/android/corepayments/graphql/GraphQLError$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/paypal/android/corepayments/graphql/GraphQLError; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/paypal/android/corepayments/graphql/GraphQLError;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLError$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class com/paypal/android/corepayments/graphql/GraphQLExtension { + public static final field Companion Lcom/paypal/android/corepayments/graphql/GraphQLExtension$Companion; public fun (Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; @@ -267,6 +285,86 @@ public final class com/paypal/android/corepayments/graphql/GraphQLExtension { public fun toString ()Ljava/lang/String; } +public final class com/paypal/android/corepayments/graphql/GraphQLExtension$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lcom/paypal/android/corepayments/graphql/GraphQLExtension$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/paypal/android/corepayments/graphql/GraphQLExtension; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/paypal/android/corepayments/graphql/GraphQLExtension;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLExtension$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLRequest { + public static final field Companion Lcom/paypal/android/corepayments/graphql/GraphQLRequest$Companion; + public fun (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/Object; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;)Lcom/paypal/android/corepayments/graphql/GraphQLRequest; + public static synthetic fun copy$default (Lcom/paypal/android/corepayments/graphql/GraphQLRequest;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)Lcom/paypal/android/corepayments/graphql/GraphQLRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getOperationName ()Ljava/lang/String; + public final fun getQuery ()Ljava/lang/String; + public final fun getVariables ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public synthetic fun (Lkotlinx/serialization/KSerializer;)V + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/paypal/android/corepayments/graphql/GraphQLRequest; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/paypal/android/corepayments/graphql/GraphQLRequest;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLRequest$Companion { + public final fun serializer (Lkotlinx/serialization/KSerializer;)Lkotlinx/serialization/KSerializer; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLResponse { + public static final field Companion Lcom/paypal/android/corepayments/graphql/GraphQLResponse$Companion; + public fun ()V + public fun (Ljava/lang/Object;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)V + public synthetic fun (Ljava/lang/Object;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Lkotlinx/serialization/json/JsonObject; + public final fun component3 ()Ljava/util/List; + public final fun copy (Ljava/lang/Object;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;)Lcom/paypal/android/corepayments/graphql/GraphQLResponse; + public static synthetic fun copy$default (Lcom/paypal/android/corepayments/graphql/GraphQLResponse;Ljava/lang/Object;Lkotlinx/serialization/json/JsonObject;Ljava/util/List;ILjava/lang/Object;)Lcom/paypal/android/corepayments/graphql/GraphQLResponse; + public fun equals (Ljava/lang/Object;)Z + public final fun getData ()Ljava/lang/Object; + public final fun getErrors ()Ljava/util/List; + public final fun getExtensions ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public synthetic fun (Lkotlinx/serialization/KSerializer;)V + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/paypal/android/corepayments/graphql/GraphQLResponse; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/paypal/android/corepayments/graphql/GraphQLResponse;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/paypal/android/corepayments/graphql/GraphQLResponse$Companion { + public final fun serializer (Lkotlinx/serialization/KSerializer;)Lkotlinx/serialization/KSerializer; +} + public abstract class com/paypal/android/corepayments/graphql/GraphQLResult { } @@ -282,20 +380,15 @@ public final class com/paypal/android/corepayments/graphql/GraphQLResult$Failure } public final class com/paypal/android/corepayments/graphql/GraphQLResult$Success : com/paypal/android/corepayments/graphql/GraphQLResult { - public fun ()V - public fun (Lorg/json/JSONObject;Ljava/util/List;Ljava/util/List;Ljava/lang/String;)V - public synthetic fun (Lorg/json/JSONObject;Ljava/util/List;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lorg/json/JSONObject; - public final fun component2 ()Ljava/util/List; - public final fun component3 ()Ljava/util/List; - public final fun component4 ()Ljava/lang/String; - public final fun copy (Lorg/json/JSONObject;Ljava/util/List;Ljava/util/List;Ljava/lang/String;)Lcom/paypal/android/corepayments/graphql/GraphQLResult$Success; - public static synthetic fun copy$default (Lcom/paypal/android/corepayments/graphql/GraphQLResult$Success;Lorg/json/JSONObject;Ljava/util/List;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Lcom/paypal/android/corepayments/graphql/GraphQLResult$Success; + public fun (Lcom/paypal/android/corepayments/graphql/GraphQLResponse;Ljava/lang/String;)V + public synthetic fun (Lcom/paypal/android/corepayments/graphql/GraphQLResponse;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/paypal/android/corepayments/graphql/GraphQLResponse; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lcom/paypal/android/corepayments/graphql/GraphQLResponse;Ljava/lang/String;)Lcom/paypal/android/corepayments/graphql/GraphQLResult$Success; + public static synthetic fun copy$default (Lcom/paypal/android/corepayments/graphql/GraphQLResult$Success;Lcom/paypal/android/corepayments/graphql/GraphQLResponse;Ljava/lang/String;ILjava/lang/Object;)Lcom/paypal/android/corepayments/graphql/GraphQLResult$Success; public fun equals (Ljava/lang/Object;)Z public final fun getCorrelationId ()Ljava/lang/String; - public final fun getData ()Lorg/json/JSONObject; - public final fun getErrors ()Ljava/util/List; - public final fun getExtensions ()Ljava/util/List; + public final fun getResponse ()Lcom/paypal/android/corepayments/graphql/GraphQLResponse; public fun hashCode ()I public fun toString ()Ljava/lang/String; } 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..49332ac3a 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt @@ -9,14 +9,14 @@ import androidx.annotation.RestrictTo object APIClientError { // 0. An unknown error occurred. - fun unknownError(correlationId: String?) = PayPalSDKError( + fun unknownError(correlationId: String? = null) = PayPalSDKError( code = PayPalSDKErrorCode.UNKNOWN.ordinal, errorDescription = "An unknown error occurred. Contact developer.paypal.com/support.", correlationId = correlationId ) // 1. Error parsing HTTP response data. - fun dataParsingError(correlationId: String?) = PayPalSDKError( + fun dataParsingError(correlationId: String? = null) = PayPalSDKError( code = PayPalSDKErrorCode.DATA_PARSING_ERROR.ordinal, errorDescription = "An error occurred parsing HTTP response data." + " Contact developer.paypal.com/support.", @@ -80,7 +80,7 @@ object APIClientError { correlationId = correlationId ) - fun graphQLJSONParseError(correlationId: String?, reason: Exception): PayPalSDKError { + fun graphQLJSONParseError(correlationId: String?, reason: Throwable): PayPalSDKError { val message = "An error occurred while parsing the GraphQL response JSON. Contact developer.paypal.com/support." val error = PayPalSDKError( diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLClient.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLClient.kt index d5ad6c970..5eacd8202 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLClient.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLClient.kt @@ -2,13 +2,20 @@ package com.paypal.android.corepayments.graphql import androidx.annotation.RestrictTo import com.paypal.android.corepayments.APIClientError +import com.paypal.android.corepayments.APIClientError.graphQLJSONParseError +import com.paypal.android.corepayments.APIClientError.invalidUrlRequest +import com.paypal.android.corepayments.APIClientError.noResponseData +import com.paypal.android.corepayments.APIClientError.unknownError import com.paypal.android.corepayments.CoreConfig import com.paypal.android.corepayments.Http import com.paypal.android.corepayments.HttpMethod import com.paypal.android.corepayments.HttpRequest -import org.json.JSONException -import org.json.JSONObject -import java.net.HttpURLConnection +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import java.net.HttpURLConnection.HTTP_OK import java.net.URL /** @@ -19,13 +26,14 @@ class GraphQLClient internal constructor( coreConfig: CoreConfig, private val http: Http = Http(), ) { - companion object { const val PAYPAL_DEBUG_ID = "Paypal-Debug-Id" } constructor(coreConfig: CoreConfig) : this(coreConfig, Http()) + private val json = Json { ignoreUnknownKeys = true } + private val graphQLEndpoint = coreConfig.environment.graphQLEndpoint private val graphQLURL = "$graphQLEndpoint/graphql" @@ -36,29 +44,74 @@ class GraphQLClient internal constructor( "Origin" to coreConfig.environment.graphQLEndpoint ) - suspend fun send(graphQLRequestBody: JSONObject, queryName: String? = null): GraphQLResult { - val body = graphQLRequestBody.toString() - val urlString = if (queryName != null) "$graphQLURL?$queryName" else graphQLURL - val httpRequest = HttpRequest(URL(urlString), HttpMethod.POST, body, httpRequestHeaders) + /** + * Implementation for sending GraphQL requests. + * Marked with @PublishedApi so it can be accessed from the inline public function. + */ + @OptIn(InternalSerializationApi::class) + @PublishedApi + internal suspend fun sendInternal( + graphQLRequest: GraphQLRequest, + variablesSerializer: KSerializer, + responseSerializer: KSerializer, + ): GraphQLResult { + val httpRequest = createHttpRequest(graphQLRequest, variablesSerializer) + ?: return GraphQLResult.Failure(error = invalidUrlRequest) val httpResponse = http.send(httpRequest) - val correlationId: String? = httpResponse.headers[PAYPAL_DEBUG_ID] - val status = httpResponse.status - return if (status == HttpURLConnection.HTTP_OK) { - if (httpResponse.body.isNullOrBlank()) { - val error = APIClientError.noResponseData(correlationId) - GraphQLResult.Failure(error) - } else { - try { - val responseAsJSON = JSONObject(httpResponse.body) - GraphQLResult.Success(responseAsJSON.getJSONObject("data"), correlationId = correlationId) - } catch (jsonParseError: JSONException) { - val error = APIClientError.graphQLJSONParseError(correlationId, jsonParseError) - GraphQLResult.Failure(error) - } + val correlationId = httpResponse.headers[PAYPAL_DEBUG_ID] + + return when { + httpResponse.status != HTTP_OK -> { + GraphQLResult.Failure(APIClientError.serverResponseError(correlationId)) + } + + httpResponse.body.isNullOrBlank() -> { + GraphQLResult.Failure(noResponseData(correlationId)) + } + + else -> runCatching { + val response = json.decodeFromString( + deserializer = GraphQLResponse.serializer(responseSerializer), + string = httpResponse.body + ) + GraphQLResult.Success(response, correlationId = correlationId) + }.getOrElse { error -> + GraphQLResult.Failure(graphQLJSONParseError(correlationId, error)) } - } else { - GraphQLResult.Success(null, correlationId = correlationId) } } + + private fun createHttpRequest( + graphQLRequest: GraphQLRequest, + variablesSerializer: KSerializer + ): HttpRequest? = runCatching { + val urlString = graphQLRequest.operationName?.let { "$graphQLURL?$it" } ?: graphQLURL + val requestBody = + json.encodeToString(GraphQLRequest.serializer(variablesSerializer), graphQLRequest) + + HttpRequest( + url = URL(urlString), + method = HttpMethod.POST, + body = requestBody, + headers = httpRequestHeaders + ) + }.getOrNull() + + /** + * Public API for sending GraphQL requests. + * Uses reified type parameters for automatic serializer selection. + */ + @OptIn(InternalSerializationApi::class) + suspend inline fun send( + graphQLRequest: GraphQLRequest + ): GraphQLResult = runCatching { + sendInternal(graphQLRequest, serializer(), serializer()) + }.getOrElse { throwable -> + val error = when (throwable) { + is SerializationException -> invalidUrlRequest + else -> unknownError() + } + GraphQLResult.Failure(error) + } } diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLError.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLError.kt index 972ecb73d..26b0831c8 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLError.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLError.kt @@ -1,11 +1,15 @@ package com.paypal.android.corepayments.graphql import androidx.annotation.RestrictTo +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable /** * @suppress */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +@InternalSerializationApi +@Serializable data class GraphQLError( val message: String, val extensions: List? = null diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLExtension.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLExtension.kt index f330ae250..56cfcd8c1 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLExtension.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLExtension.kt @@ -1,11 +1,15 @@ package com.paypal.android.corepayments.graphql import androidx.annotation.RestrictTo +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable /** * @suppress */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +@InternalSerializationApi +@Serializable data class GraphQLExtension( val correlationId: String, val code: String? = null diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLRequest.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLRequest.kt new file mode 100644 index 000000000..909c974cb --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLRequest.kt @@ -0,0 +1,15 @@ +package com.paypal.android.corepayments.graphql + +import androidx.annotation.RestrictTo +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +@OptIn(InternalSerializationApi::class) +@Serializable +data class GraphQLRequest( + val query: String, + val variables: V? = null, + @kotlinx.serialization.Transient + val operationName: String? = null +) diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResponse.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResponse.kt new file mode 100644 index 000000000..972659d50 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResponse.kt @@ -0,0 +1,15 @@ +package com.paypal.android.corepayments.graphql + +import androidx.annotation.RestrictTo +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +@InternalSerializationApi +@Serializable +data class GraphQLResponse @OptIn(InternalSerializationApi::class) constructor( + val data: T? = null, + val extensions: JsonObject? = null, + val errors: List? = null, +) diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResult.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResult.kt index 85481c5bc..5121a241a 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResult.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/graphql/GraphQLResult.kt @@ -2,20 +2,19 @@ package com.paypal.android.corepayments.graphql import androidx.annotation.RestrictTo import com.paypal.android.corepayments.PayPalSDKError -import org.json.JSONObject +import kotlinx.serialization.InternalSerializationApi /** * @suppress */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -sealed class GraphQLResult { +sealed class GraphQLResult { - data class Success( - val data: JSONObject? = null, - val extensions: List? = null, - val errors: List? = null, + @OptIn(InternalSerializationApi::class) + data class Success( + val response: GraphQLResponse, val correlationId: String? = null - ) : GraphQLResult() + ) : GraphQLResult() - data class Failure(val error: PayPalSDKError) : GraphQLResult() + data class Failure(val error: PayPalSDKError) : GraphQLResult() } diff --git a/CorePayments/src/test/java/com/paypal/android/corepayments/graphql/GraphQLClientUnitTest.kt b/CorePayments/src/test/java/com/paypal/android/corepayments/graphql/GraphQLClientUnitTest.kt index 01f2abb6f..aef0d71b7 100644 --- a/CorePayments/src/test/java/com/paypal/android/corepayments/graphql/GraphQLClientUnitTest.kt +++ b/CorePayments/src/test/java/com/paypal/android/corepayments/graphql/GraphQLClientUnitTest.kt @@ -7,15 +7,18 @@ import com.paypal.android.corepayments.HttpMethod import com.paypal.android.corepayments.HttpRequest import com.paypal.android.corepayments.HttpResponse import com.paypal.android.corepayments.PayPalSDKErrorCode -import io.mockk.CapturingSlot import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.slot import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import org.json.JSONObject +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -23,149 +26,257 @@ import org.robolectric.RobolectricTestRunner import java.net.URL @ExperimentalCoroutinesApi +@OptIn(InternalSerializationApi::class) @RunWith(RobolectricTestRunner::class) internal class GraphQLClientUnitTest { - private val sandboxConfig = CoreConfig("fake-client-id", Environment.SANDBOX) - private val liveConfig = CoreConfig("fake-client-id", Environment.LIVE) + // Test data and fixtures + @Serializable + private data class TestRequest(val id: String) - private val graphQLRequestBody = JSONObject("""{"fake":"json"}""") + @Serializable + private data class TestResponse(val result: String) - private lateinit var http: Http - private lateinit var httpRequestSlot: CapturingSlot - - private lateinit var sut: GraphQLClient + // Constants for tests + private val testQuery = "query { test }" + private val testVariables = TestRequest("test-id") + private val testOperationName = "TestOperation" + private val testCorrelationId = "test-correlation-id" + // Mocks + private lateinit var mockHttp: Http + private lateinit var mockCoreConfig: CoreConfig + private lateinit var graphQLClient: GraphQLClient @Before - fun setUp() { - http = mockk(relaxed = true) - httpRequestSlot = slot() - } + fun setup() { + mockHttp = mockk(relaxed = true) + mockCoreConfig = mockk(relaxed = true) - @Test - fun `send sends an http request to sandbox environment`() = runTest { - sut = GraphQLClient(sandboxConfig, http) - sut.send(graphQLRequestBody) - coVerify { http.send(capture(httpRequestSlot)) } - - val httpRequest = httpRequestSlot.captured - assertEquals(URL("https://www.sandbox.paypal.com/graphql"), httpRequest.url) - assertEquals("https://www.sandbox.paypal.com", httpRequest.headers["Origin"]) - } + // Setup mock environment for the CoreConfig + val mockEnvironment = mockk(relaxed = true) + every { mockEnvironment.graphQLEndpoint } returns "https://test-api.paypal.com" + every { mockCoreConfig.environment } returns mockEnvironment - @Test - fun `send sends an http request to sandbox environment with query name appended`() = runTest { - sut = GraphQLClient(sandboxConfig, http) - sut.send(graphQLRequestBody, "QueryName") - coVerify { http.send(capture(httpRequestSlot)) } - - val httpRequest = httpRequestSlot.captured - assertEquals(URL("https://www.sandbox.paypal.com/graphql?QueryName"), httpRequest.url) - assertEquals("https://www.sandbox.paypal.com", httpRequest.headers["Origin"]) + // Create the GraphQLClient with mocked dependencies + graphQLClient = GraphQLClient(mockCoreConfig, mockHttp) } @Test - fun `send sends an http request to live environment`() = runTest { - sut = GraphQLClient(liveConfig, http) - sut.send(graphQLRequestBody) - coVerify { http.send(capture(httpRequestSlot)) } - - val httpRequest = httpRequestSlot.captured - assertEquals(URL("https://www.paypal.com/graphql"), httpRequest.url) - assertEquals("https://www.paypal.com", httpRequest.headers["Origin"]) - } + fun `test successful GraphQL request and response`() = runTest { + // Arrange + val expectedUrl = URL("https://test-api.paypal.com/graphql?$testOperationName") + val graphQLRequest = GraphQLRequest( + query = testQuery, + variables = testVariables, + operationName = testOperationName + ) - @Test - fun `send sends an http request to live environment with query name appended`() = runTest { - sut = GraphQLClient(liveConfig, http) - sut.send(graphQLRequestBody, "QueryName") - coVerify { http.send(capture(httpRequestSlot)) } - - val httpRequest = httpRequestSlot.captured - assertEquals(URL("https://www.paypal.com/graphql?QueryName"), httpRequest.url) - assertEquals("https://www.paypal.com", httpRequest.headers["Origin"]) - } + // Mock HTTP response + val successfulResponseJson = """ + { + "data": { + "result": "success" + } + } + """.trimIndent() - @Test - fun `send forwards graphQL request body as an http request body`() = runTest { - sut = GraphQLClient(liveConfig, http) - sut.send(graphQLRequestBody) - coVerify { http.send(capture(httpRequestSlot)) } + val headers = mapOf(GraphQLClient.PAYPAL_DEBUG_ID to testCorrelationId) + val mockResponse = HttpResponse( + status = 200, + body = successfulResponseJson, + headers = headers + ) + + // Capture the HttpRequest to verify it later + val httpRequestSlot = slot() + coEvery { mockHttp.send(capture(httpRequestSlot)) } returns mockResponse + + // Act + val result = graphQLClient.send( + graphQLRequest + ) - val httpRequest = httpRequestSlot.captured - assertEquals("""{"fake":"json"}""", httpRequest.body) + // Assert + assertTrue(result is GraphQLResult.Success) + val successResult = result as GraphQLResult.Success + + // Verify response data + assertEquals(testCorrelationId, successResult.correlationId) + assertNotNull(successResult.response.data) + assertEquals("success", successResult.response.data?.result) + + // Verify the HTTP request was created correctly + val capturedRequest = httpRequestSlot.captured + assertEquals(expectedUrl, capturedRequest.url) + assertEquals(HttpMethod.POST, capturedRequest.method) + val expectedRequestBody = """{"query":"$testQuery","variables":{"id":"test-id"}}""" + assertEquals(expectedRequestBody, capturedRequest.body) + + // Verify HTTP send was called + coVerify(exactly = 1) { mockHttp.send(any()) } } + @OptIn(InternalSerializationApi::class) @Test - fun `send sends an HTTP POST request`() = runTest { - sut = GraphQLClient(sandboxConfig, http) - sut.send(graphQLRequestBody) - coVerify { http.send(capture(httpRequestSlot)) } + fun `test HTTP error response handling`() = runTest { + // Arrange + val graphQLRequest = GraphQLRequest( + query = testQuery, + variables = testVariables + ) + + // Create an HTTP error response (non-200 status code) + val headers = mapOf(GraphQLClient.PAYPAL_DEBUG_ID to testCorrelationId) + val mockResponse = HttpResponse( + status = 400, // Bad Request + body = null, + headers = headers + ) + + coEvery { mockHttp.send(any()) } returns mockResponse + + // Act + val result = graphQLClient.send( + graphQLRequest + ) - val httpRequest = httpRequestSlot.captured - assertEquals(HttpMethod.POST, httpRequest.method) + // Assert + assertTrue(result is GraphQLResult.Failure) + val failureResult = result as GraphQLResult.Failure + + // Verify we get an error response for HTTP error + assertEquals(testCorrelationId, failureResult.error.correlationId) + + // Verify HTTP send was called + coVerify(exactly = 1) { mockHttp.send(any()) } } @Test - fun `send sets default headers`() = runTest { - sut = GraphQLClient(sandboxConfig, http) - sut.send(graphQLRequestBody) - coVerify { http.send(capture(httpRequestSlot)) } - - val httpRequest = httpRequestSlot.captured - assertEquals("application/json", httpRequest.headers["Content-Type"]) - assertEquals("application/json", httpRequest.headers["Accept"]) - assertEquals("nativecheckout", httpRequest.headers["x-app-name"]) + fun `test empty response body handling`() = runTest { + // Arrange + val graphQLRequest = GraphQLRequest( + query = testQuery, + variables = testVariables + ) + + // Create a response with empty body but 200 status + val headers = mapOf(GraphQLClient.PAYPAL_DEBUG_ID to testCorrelationId) + val mockResponse = HttpResponse( + status = 200, + body = "", // Empty body + headers = headers + ) + + coEvery { mockHttp.send(any()) } returns mockResponse + + // Act + val result = graphQLClient.send( + graphQLRequest + ) + + // Assert + assertTrue(result is GraphQLResult.Failure) + val failureResult = result as GraphQLResult.Failure + + // Verify we get an error about no response data + assertEquals(PayPalSDKErrorCode.NO_RESPONSE_DATA.ordinal, failureResult.error.code) + assertEquals(testCorrelationId, failureResult.error.correlationId) + + // Verify HTTP send was called + coVerify(exactly = 1) { mockHttp.send(any()) } } @Test - fun `send parses GraphQL success response`() = runTest { - // language=JSON - val successBody = """{ "data": { "fake": "success_data" } }""" - val successHeaders = mapOf("Paypal-Debug-Id" to "fake-debug-id") - val successHttpResponse = HttpResponse(200, successHeaders, successBody) - coEvery { http.send(any()) } returns successHttpResponse - - sut = GraphQLClient(sandboxConfig, http) - val result = sut.send(graphQLRequestBody) as GraphQLResult.Success - - assertEquals("""{"fake":"success_data"}""", result.data?.toString()) - assertEquals("fake-debug-id", result.correlationId) + fun `test JSON parse error handling`() = runTest { + // Arrange + val graphQLRequest = GraphQLRequest( + query = testQuery, + variables = testVariables + ) + + // Create a response with invalid JSON + val headers = mapOf(GraphQLClient.PAYPAL_DEBUG_ID to testCorrelationId) + val mockResponse = HttpResponse( + status = 200, + body = "{invalid json that will cause parsing error", // Invalid JSON + headers = headers + ) + + coEvery { mockHttp.send(any()) } returns mockResponse + + // Act + val result = graphQLClient.send( + graphQLRequest + ) + + // Assert + assertTrue(result is GraphQLResult.Failure) + val failureResult = result as GraphQLResult.Failure + + // Verify we get an error about JSON parsing + assertEquals( + PayPalSDKErrorCode.GRAPHQL_JSON_INVALID_ERROR.ordinal, + failureResult.error.code + ) + assertEquals(testCorrelationId, failureResult.error.correlationId) + + // Verify HTTP send was called + coVerify(exactly = 1) { mockHttp.send(any()) } } @Test - fun `send returns an error when GraphQL response is successful with an empty body`() = runTest { - // language=JSON - val emptyBody = "" - val successHeaders = mapOf("Paypal-Debug-Id" to "fake-debug-id") - val successHttpResponse = HttpResponse(200, successHeaders, emptyBody) - coEvery { http.send(any()) } returns successHttpResponse - - sut = GraphQLClient(sandboxConfig, http) - val result = sut.send(graphQLRequestBody) as GraphQLResult.Failure - assertEquals(PayPalSDKErrorCode.NO_RESPONSE_DATA.ordinal, result.error.code) - - val expectedErrorMessage = - "An error occurred due to missing HTTP response data. Contact developer.paypal.com/support." - assertEquals(expectedErrorMessage, result.error.errorDescription) - assertEquals("fake-debug-id", result.error.correlationId) + fun `test invalid URL handling`() = runTest { + // Arrange - setup a situation where creating the URL will fail + // We'll use a mock environment with an invalid URL format + val invalidMockEnvironment = mockk(relaxed = true) + every { invalidMockEnvironment.graphQLEndpoint } returns "invalid://endpoint" + + val invalidMockCoreConfig = mockk(relaxed = true) + every { invalidMockCoreConfig.environment } returns invalidMockEnvironment + + val invalidUrlClient = GraphQLClient(invalidMockCoreConfig, mockHttp) + + val graphQLRequest = GraphQLRequest( + query = testQuery, + variables = testVariables, + operationName = "$" // Invalid character in URL + ) + + // Act + val result = invalidUrlClient.send( + graphQLRequest + ) + + // Assert + assertTrue(result is GraphQLResult.Failure) + val failureResult = result as GraphQLResult.Failure + + // Verify we get an error about invalid URL + assertEquals(PayPalSDKErrorCode.INVALID_URL_REQUEST.ordinal, failureResult.error.code) + + // Verify HTTP send was not called (since URL creation failed) + coVerify(exactly = 0) { mockHttp.send(any()) } } @Test - fun `send returns an error when GraphQL response is successful with an invalid JSON body`() = - runTest { - val invalidJSON = """{ invalid: """ - val successHeaders = mapOf("Paypal-Debug-Id" to "fake-debug-id") - val successHttpResponse = HttpResponse(200, successHeaders, invalidJSON) - coEvery { http.send(any()) } returns successHttpResponse - - sut = GraphQLClient(sandboxConfig, http) - val result = sut.send(graphQLRequestBody) as GraphQLResult.Failure - assertEquals(PayPalSDKErrorCode.GRAPHQL_JSON_INVALID_ERROR.ordinal, result.error.code) - - val expectedErrorMessage = - "An error occurred while parsing the GraphQL response JSON. Contact developer.paypal.com/support." - assertEquals(expectedErrorMessage, result.error.errorDescription) - assertEquals("fake-debug-id", result.error.correlationId) - } + fun `test request serialization failure`() = runTest { + + data class NonSerializable(val s: String) + + val graphQLRequest = GraphQLRequest( + query = testQuery, + variables = NonSerializable("non serializable") + ) + + // Act + val result: GraphQLResult = graphQLClient.send(graphQLRequest) + + // Assert + assertTrue(result is GraphQLResult.Failure) + val failureResult = result as GraphQLResult.Failure + assertEquals(PayPalSDKErrorCode.INVALID_URL_REQUEST.ordinal, failureResult.error.code) + + // Verify HTTP send was NOT called since the request creation failed + coVerify(exactly = 0) { mockHttp.send(any()) } + } }