diff --git a/PayPal.xcodeproj/project.pbxproj b/PayPal.xcodeproj/project.pbxproj index ec9291bcb..b15bef42d 100644 --- a/PayPal.xcodeproj/project.pbxproj +++ b/PayPal.xcodeproj/project.pbxproj @@ -15,20 +15,21 @@ 06CE009926F3D1660000CC46 /* CoreConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CE009826F3D1660000CC46 /* CoreConfig.swift */; }; 06CE009B26F3D5A40000CC46 /* CardClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CE009A26F3D5A40000CC46 /* CardClient.swift */; }; 3B109B3C2A85CC6200D8135F /* MockCardVaultDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B109B3A2A85B54800D8135F /* MockCardVaultDelegate.swift */; }; - 3B109B3D2A85D1CA00D8135F /* MockGraphQLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B109B382A85A9DB00D8135F /* MockGraphQLClient.swift */; }; 3B22E8B62A840ECF00962E34 /* CardVaultDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B22E8B52A840ECF00962E34 /* CardVaultDelegate.swift */; }; 3B22E8B82A841AEA00962E34 /* CardVaultResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B22E8B72A841AEA00962E34 /* CardVaultResult.swift */; }; - 3B79E4F72A8503CA00C01D06 /* UpdateSetupTokenQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B79E4F62A8503C900C01D06 /* UpdateSetupTokenQuery.swift */; }; + 3B79E4F72A8503CA00C01D06 /* UpdateVaultVariables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B79E4F62A8503C900C01D06 /* UpdateVaultVariables.swift */; }; 3B80D50C2A27979000D2EAC4 /* FailingJSONEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B80D50B2A27979000D2EAC4 /* FailingJSONEncoder.swift */; }; 3BD82DBB2A835AF900CBE764 /* UpdateSetupTokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD82DBA2A835AF900CBE764 /* UpdateSetupTokenResponse.swift */; }; 3BDB34942A80CE6E008100D7 /* CardVaultRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDB34932A80CE6E008100D7 /* CardVaultRequest.swift */; }; 3D1763A22720722A00652E1C /* CardResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1763A12720722A00652E1C /* CardResult.swift */; }; 3DC42BA927187E8300B71645 /* ErrorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC42BA827187E8300B71645 /* ErrorResponse.swift */; }; 53A2A4E228A182AC0093441C /* NativeCheckoutProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A2A4E128A182AC0093441C /* NativeCheckoutProvider.swift */; }; + 8008D2052A9E54FF0003CAF4 /* CheckoutOrdersAPI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8008D2042A9E54FF0003CAF4 /* CheckoutOrdersAPI_Tests.swift */; }; 80132D7229008C000088D30D /* TestShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80E743F8270E40CE00BACECA /* TestShared.framework */; }; 8021B69029144E6D000FBC54 /* PayPalCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8021B68F29144E6D000FBC54 /* PayPalCoreConstants.swift */; }; 802C4A742945670400896A5D /* MockHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802C4A732945670400896A5D /* MockHTTP.swift */; }; 802C4A762945676E00896A5D /* AnalyticsService_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802C4A752945676E00896A5D /* AnalyticsService_Tests.swift */; }; + 802EFBDB2A96B47A00AB709D /* TrackingEventsAPI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802EFBD92A968BCD00AB709D /* TrackingEventsAPI_Tests.swift */; }; 8034A9E726B875C90055AF13 /* CorePayments.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80B9F85126B8750000D67843 /* CorePayments.framework */; }; 8036C1E4270F9BE700C0F091 /* APIClient_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8036C1E0270F9BE700C0F091 /* APIClient_Tests.swift */; }; 8036C1E5270F9BE700C0F091 /* Environment_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8036C1E1270F9BE700C0F091 /* Environment_Tests.swift */; }; @@ -44,9 +45,16 @@ 8071AFA729C8BEF3008A39E9 /* PayPalNativeShippingAddress_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8071AFA629C8BEF3008A39E9 /* PayPalNativeShippingAddress_Tests.swift */; }; 807BF58F2A2A5D19002F32B3 /* HTTPResponseParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807BF58E2A2A5D19002F32B3 /* HTTPResponseParser.swift */; }; 807BF5912A2A5D48002F32B3 /* HTTPResponseParser_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807BF5902A2A5D48002F32B3 /* HTTPResponseParser_Tests.swift */; }; - 807C5E67291027D400ECECD8 /* AnalyticsEventRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807C5E66291027D400ECECD8 /* AnalyticsEventRequest.swift */; }; 807C5E6929102D9800ECECD8 /* AnalyticsEventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807C5E6829102D9800ECECD8 /* AnalyticsEventData.swift */; }; - 808EEA81291321FE001B6765 /* AnalyticsEventRequest_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808EEA80291321FE001B6765 /* AnalyticsEventRequest_Tests.swift */; }; + 807D56AC2A869044009E591D /* GraphQLHTTPPostBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807D56AB2A869044009E591D /* GraphQLHTTPPostBody.swift */; }; + 807D56AE2A869064009E591D /* GraphQLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807D56AD2A869064009E591D /* GraphQLRequest.swift */; }; + 807D56B02A869F97009E591D /* GraphQLErrorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807D56AF2A869F97009E591D /* GraphQLErrorResponse.swift */; }; + 808E3EDD2A981F240017FE46 /* MockCheckoutOrdersAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E3EDC2A981F240017FE46 /* MockCheckoutOrdersAPI.swift */; }; + 808E3EDF2A981F390017FE46 /* MockVaultPaymentTokensAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E3EDE2A981F390017FE46 /* MockVaultPaymentTokensAPI.swift */; }; + 808EEA81291321FE001B6765 /* AnalyticsEventData_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808EEA80291321FE001B6765 /* AnalyticsEventData_Tests.swift */; }; + 80B27AF12A9E9EE60008EA45 /* VaultPaymentTokensAPI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B27AF02A9E9EE60008EA45 /* VaultPaymentTokensAPI_Tests.swift */; }; + 80B8B2FC2A8EBBFD00AB60CD /* VaultPaymentTokensAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B8B2FB2A8EBBFD00AB60CD /* VaultPaymentTokensAPI.swift */; }; + 80B96AAE2A980F6B00C62916 /* MockTrackingEventsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802EFBD72A9685DF00AB709D /* MockTrackingEventsAPI.swift */; }; 80D0C1382731CC9B00548A3D /* PayPalNativeCheckoutClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7116152723227200165069 /* PayPalNativeCheckoutClient.swift */; }; 80DB2F762980795D00CFB86A /* CorePaymentsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DB2F752980795D00CFB86A /* CorePaymentsError.swift */; }; 80DB6F1726B89DC700277E54 /* CorePayments.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80B9F85126B8750000D67843 /* CorePayments.framework */; platformFilter = ios; }; @@ -55,6 +63,10 @@ 80DBC9DE29C3B57200462539 /* PayPalNativeShippingAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DBC9DD29C3B57200462539 /* PayPalNativeShippingAddress.swift */; }; 80DBC9E029C3B8A800462539 /* PayPalNativeShippingMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DBC9DF29C3B8A800462539 /* PayPalNativeShippingMethod.swift */; }; 80DCC59E2719DB6F00EC7C5A /* CardClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DCC59D2719DB6F00EC7C5A /* CardClientError.swift */; }; + 80E237DF2A84434B00FF18CA /* HTTPRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E237DE2A84434B00FF18CA /* HTTPRequest.swift */; }; + 80E2FDBE2A83528B0045593D /* CheckoutOrdersAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E2FDBD2A83528B0045593D /* CheckoutOrdersAPI.swift */; }; + 80E2FDC12A83535A0045593D /* TrackingEventsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E2FDBF2A8353550045593D /* TrackingEventsAPI.swift */; }; + 80E2FDC32A8354AD0045593D /* RESTRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E2FDC22A8354AD0045593D /* RESTRequest.swift */; }; 80E643832A1EBBD2008FD705 /* HTTPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E643822A1EBBD2008FD705 /* HTTPResponse.swift */; }; 80E74400270E40F300BACECA /* FakeRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E743FF270E40F300BACECA /* FakeRequests.swift */; }; 80E8DAE126B8784600FAFC3F /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8DAE026B8784600FAFC3F /* Card.swift */; }; @@ -109,12 +121,11 @@ BE71161C27234B8A00165069 /* PayPalNativePaymentsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE71161B27234B8A00165069 /* PayPalNativePaymentsError.swift */; }; BE711621272358E200165069 /* Environment+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE711620272358E200165069 /* Environment+Extension.swift */; }; BEA100E726EF9EDA0036A6A5 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA100E626EF9EDA0036A6A5 /* APIClient.swift */; }; - BEA100E926EFA1EE0036A6A5 /* APIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA100E826EFA1EE0036A6A5 /* APIRequest.swift */; }; BEA100EC26EFA7790036A6A5 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA100EB26EFA7790036A6A5 /* HTTPMethod.swift */; }; BEA100EE26EFA7990036A6A5 /* HTTPHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA100ED26EFA7990036A6A5 /* HTTPHeader.swift */; }; BEA100F026EFA7C20036A6A5 /* APIClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA100EF26EFA7C20036A6A5 /* APIClientError.swift */; }; BEA100F226EFA7DE0036A6A5 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA100F126EFA7DE0036A6A5 /* Environment.swift */; }; - CB16E6D8285B7A2B00FD6F52 /* CardResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB16E6D7285B7A2B00FD6F52 /* CardResponses.swift */; }; + CB16E6D8285B7A2B00FD6F52 /* FakeConfirmPaymentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB16E6D7285B7A2B00FD6F52 /* FakeConfirmPaymentResponse.swift */; }; CB16E6DA285B7B7300FD6F52 /* MockCardDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB16E6D9285B7B7300FD6F52 /* MockCardDelegate.swift */; }; CB1A47F22820AFED00BD8184 /* PayPalPayLaterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB1A47F12820AFED00BD8184 /* PayPalPayLaterButton.swift */; }; CB1A47F42820BA5D00BD8184 /* PaymentButtonEdges.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB1A47F32820BA5D00BD8184 /* PaymentButtonEdges.swift */; }; @@ -139,12 +150,8 @@ CBC16DE029EE2F8200307117 /* PayPalNativePaysheetAction_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC16DDF29EE2F8200307117 /* PayPalNativePaysheetAction_Tests.swift */; }; CBC16DF629EECCB900307117 /* NativeCheckoutProvider_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC16DF529EECCB900307117 /* NativeCheckoutProvider_Tests.swift */; }; CBD6004728D0C24A00C3EFF6 /* MockPayPalDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD6004628D0C24900C3EFF6 /* MockPayPalDelegate.swift */; }; - E6022E802857C6BE008B0E27 /* GraphQLQueryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64623222836A69E008AC8E1 /* GraphQLQueryResponse.swift */; }; - E6022E822857C6BE008B0E27 /* GraphQLError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64623262836A81E008AC8E1 /* GraphQLError.swift */; }; - E6022E832857C6BE008B0E27 /* GraphQLQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E646231B28369B9B008AC8E1 /* GraphQLQuery.swift */; }; - E6022E842857C6BE008B0E27 /* GraphQLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64623372836AFC1008AC8E1 /* GraphQLClient.swift */; }; + E6022E802857C6BE008B0E27 /* GraphQLHTTPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64623222836A69E008AC8E1 /* GraphQLHTTPResponse.swift */; }; E64763712899B60C00074113 /* MockAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64763702899B60C00074113 /* MockAPIClient.swift */; }; - E699EC16285A388E0044A753 /* GraphQLClient_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E699EC15285A388E0044A753 /* GraphQLClient_Tests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -203,11 +210,10 @@ 06CE009A26F3D5A40000CC46 /* CardClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardClient.swift; sourceTree = ""; }; 06CE009F26F3DF100000CC46 /* ConfirmPaymentSourceRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmPaymentSourceRequest.swift; sourceTree = ""; }; 06CE00A226F3E32A0000CC46 /* ConfirmPaymentSourceResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmPaymentSourceResponse.swift; sourceTree = ""; }; - 3B109B382A85A9DB00D8135F /* MockGraphQLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGraphQLClient.swift; sourceTree = ""; }; 3B109B3A2A85B54800D8135F /* MockCardVaultDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCardVaultDelegate.swift; sourceTree = ""; }; 3B22E8B52A840ECF00962E34 /* CardVaultDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVaultDelegate.swift; sourceTree = ""; }; 3B22E8B72A841AEA00962E34 /* CardVaultResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVaultResult.swift; sourceTree = ""; }; - 3B79E4F62A8503C900C01D06 /* UpdateSetupTokenQuery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateSetupTokenQuery.swift; sourceTree = ""; }; + 3B79E4F62A8503C900C01D06 /* UpdateVaultVariables.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateVaultVariables.swift; sourceTree = ""; }; 3B80D50B2A27979000D2EAC4 /* FailingJSONEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailingJSONEncoder.swift; sourceTree = ""; }; 3BD82DBA2A835AF900CBE764 /* UpdateSetupTokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSetupTokenResponse.swift; sourceTree = ""; }; 3BDB34932A80CE6E008100D7 /* CardVaultRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVaultRequest.swift; sourceTree = ""; }; @@ -216,9 +222,12 @@ 3D25238B273979170099E4EB /* MockNativeCheckoutProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNativeCheckoutProvider.swift; sourceTree = ""; }; 3DC42BA827187E8300B71645 /* ErrorResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorResponse.swift; sourceTree = ""; }; 53A2A4E128A182AC0093441C /* NativeCheckoutProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCheckoutProvider.swift; sourceTree = ""; }; + 8008D2042A9E54FF0003CAF4 /* CheckoutOrdersAPI_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutOrdersAPI_Tests.swift; sourceTree = ""; }; 8021B68F29144E6D000FBC54 /* PayPalCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalCoreConstants.swift; sourceTree = ""; }; 802C4A732945670400896A5D /* MockHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockHTTP.swift; sourceTree = ""; }; 802C4A752945676E00896A5D /* AnalyticsService_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService_Tests.swift; sourceTree = ""; }; + 802EFBD72A9685DF00AB709D /* MockTrackingEventsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTrackingEventsAPI.swift; sourceTree = ""; }; + 802EFBD92A968BCD00AB709D /* TrackingEventsAPI_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingEventsAPI_Tests.swift; sourceTree = ""; }; 8034A9E326B875C90055AF13 /* CorePaymentsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CorePaymentsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8036C1E0270F9BE700C0F091 /* APIClient_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = APIClient_Tests.swift; path = UnitTests/PaymentsCoreTests/APIClient_Tests.swift; sourceTree = SOURCE_ROOT; }; 8036C1E1270F9BE700C0F091 /* Environment_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Environment_Tests.swift; path = UnitTests/PaymentsCoreTests/Environment_Tests.swift; sourceTree = SOURCE_ROOT; }; @@ -230,9 +239,15 @@ 8071AFA629C8BEF3008A39E9 /* PayPalNativeShippingAddress_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalNativeShippingAddress_Tests.swift; sourceTree = ""; }; 807BF58E2A2A5D19002F32B3 /* HTTPResponseParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseParser.swift; sourceTree = ""; }; 807BF5902A2A5D48002F32B3 /* HTTPResponseParser_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponseParser_Tests.swift; sourceTree = ""; }; - 807C5E66291027D400ECECD8 /* AnalyticsEventRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventRequest.swift; sourceTree = ""; }; 807C5E6829102D9800ECECD8 /* AnalyticsEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventData.swift; sourceTree = ""; }; - 808EEA80291321FE001B6765 /* AnalyticsEventRequest_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventRequest_Tests.swift; sourceTree = ""; }; + 807D56AB2A869044009E591D /* GraphQLHTTPPostBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLHTTPPostBody.swift; sourceTree = ""; }; + 807D56AD2A869064009E591D /* GraphQLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLRequest.swift; sourceTree = ""; }; + 807D56AF2A869F97009E591D /* GraphQLErrorResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLErrorResponse.swift; sourceTree = ""; }; + 808E3EDC2A981F240017FE46 /* MockCheckoutOrdersAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCheckoutOrdersAPI.swift; sourceTree = ""; }; + 808E3EDE2A981F390017FE46 /* MockVaultPaymentTokensAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockVaultPaymentTokensAPI.swift; sourceTree = ""; }; + 808EEA80291321FE001B6765 /* AnalyticsEventData_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventData_Tests.swift; sourceTree = ""; }; + 80B27AF02A9E9EE60008EA45 /* VaultPaymentTokensAPI_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultPaymentTokensAPI_Tests.swift; sourceTree = ""; }; + 80B8B2FB2A8EBBFD00AB60CD /* VaultPaymentTokensAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultPaymentTokensAPI.swift; sourceTree = ""; }; 80B9F85126B8750000D67843 /* CorePayments.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CorePayments.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 80DB2F752980795D00CFB86A /* CorePaymentsError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePaymentsError.swift; sourceTree = ""; }; 80DB6F0E26B89D9600277E54 /* PayPalNativePayments.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PayPalNativePayments.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -241,6 +256,10 @@ 80DBC9DD29C3B57200462539 /* PayPalNativeShippingAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalNativeShippingAddress.swift; sourceTree = ""; }; 80DBC9DF29C3B8A800462539 /* PayPalNativeShippingMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalNativeShippingMethod.swift; sourceTree = ""; }; 80DCC59D2719DB6F00EC7C5A /* CardClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardClientError.swift; sourceTree = ""; }; + 80E237DE2A84434B00FF18CA /* HTTPRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequest.swift; sourceTree = ""; }; + 80E2FDBD2A83528B0045593D /* CheckoutOrdersAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutOrdersAPI.swift; sourceTree = ""; }; + 80E2FDBF2A8353550045593D /* TrackingEventsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingEventsAPI.swift; sourceTree = ""; }; + 80E2FDC22A8354AD0045593D /* RESTRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRequest.swift; sourceTree = ""; }; 80E643822A1EBBD2008FD705 /* HTTPResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponse.swift; sourceTree = ""; }; 80E743F8270E40CE00BACECA /* TestShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TestShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 80E743FF270E40F300BACECA /* FakeRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeRequests.swift; sourceTree = ""; }; @@ -295,14 +314,13 @@ BE9F36E0274859C200AFC7DA /* PaymentButtonImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentButtonImage.swift; sourceTree = ""; }; BE9F36E3275520E700AFC7DA /* PayPalCreditButton_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalCreditButton_Tests.swift; sourceTree = ""; }; BEA100E626EF9EDA0036A6A5 /* APIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; - BEA100E826EFA1EE0036A6A5 /* APIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequest.swift; sourceTree = ""; }; BEA100EB26EFA7790036A6A5 /* HTTPMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = ""; }; BEA100ED26EFA7990036A6A5 /* HTTPHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeader.swift; sourceTree = ""; }; BEA100EF26EFA7C20036A6A5 /* APIClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientError.swift; sourceTree = ""; }; BEA100F126EFA7DE0036A6A5 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; BEDB7FE32788AB8E00CEA554 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; BEF3FF1627AC5DF3006B4B69 /* Coordinator_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator_Tests.swift; sourceTree = ""; }; - CB16E6D7285B7A2B00FD6F52 /* CardResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardResponses.swift; sourceTree = ""; }; + CB16E6D7285B7A2B00FD6F52 /* FakeConfirmPaymentResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeConfirmPaymentResponse.swift; sourceTree = ""; }; CB16E6D9285B7B7300FD6F52 /* MockCardDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCardDelegate.swift; sourceTree = ""; }; CB1A47F12820AFED00BD8184 /* PayPalPayLaterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalPayLaterButton.swift; sourceTree = ""; }; CB1A47F32820BA5D00BD8184 /* PaymentButtonEdges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentButtonEdges.swift; sourceTree = ""; }; @@ -323,12 +341,8 @@ CBC16DDF29EE2F8200307117 /* PayPalNativePaysheetAction_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayPalNativePaysheetAction_Tests.swift; sourceTree = ""; }; CBC16DF529EECCB900307117 /* NativeCheckoutProvider_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCheckoutProvider_Tests.swift; sourceTree = ""; }; CBD6004628D0C24900C3EFF6 /* MockPayPalDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPayPalDelegate.swift; sourceTree = ""; }; - E646231B28369B9B008AC8E1 /* GraphQLQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLQuery.swift; sourceTree = ""; }; - E64623222836A69E008AC8E1 /* GraphQLQueryResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLQueryResponse.swift; sourceTree = ""; }; - E64623262836A81E008AC8E1 /* GraphQLError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLError.swift; sourceTree = ""; }; - E64623372836AFC1008AC8E1 /* GraphQLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLClient.swift; sourceTree = ""; }; + E64623222836A69E008AC8E1 /* GraphQLHTTPResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLHTTPResponse.swift; sourceTree = ""; }; E64763702899B60C00074113 /* MockAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAPIClient.swift; sourceTree = ""; }; - E699EC15285A388E0044A753 /* GraphQLClient_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLClient_Tests.swift; sourceTree = ""; }; OBJ_16 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; OBJ_6 /* Package.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; "PayPal::PayPalTests::Product" /* PayPalNativeCheckoutTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = PayPalNativeCheckoutTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -485,14 +499,16 @@ 8036C1DE270F9BCF00C0F091 /* PaymentsCoreTests */ = { isa = PBXGroup; children = ( + 808EEA80291321FE001B6765 /* AnalyticsEventData_Tests.swift */, 8036C1E0270F9BE700C0F091 /* APIClient_Tests.swift */, 802C4A752945676E00896A5D /* AnalyticsService_Tests.swift */, + 8036C1E0270F9BE700C0F091 /* APIClient_Tests.swift */, 8036C1E1270F9BE700C0F091 /* Environment_Tests.swift */, - E699EC15285A388E0044A753 /* GraphQLClient_Tests.swift */, - 808EEA80291321FE001B6765 /* AnalyticsEventRequest_Tests.swift */, 80FC261C29847AC7008EC841 /* HTTP_Tests.swift */, - 807BF5902A2A5D48002F32B3 /* HTTPResponseParser_Tests.swift */, 8036F87F2A30E492005B6186 /* HTTPResponse_Tests.swift */, + 807BF5902A2A5D48002F32B3 /* HTTPResponseParser_Tests.swift */, + 802EFBD92A968BCD00AB709D /* TrackingEventsAPI_Tests.swift */, + 80B96AAD2A9806B300C62916 /* Mocks */, ); path = PaymentsCoreTests; sourceTree = ""; @@ -505,6 +521,26 @@ name = Frameworks; sourceTree = ""; }; + 808E3EDB2A981F130017FE46 /* Mocks */ = { + isa = PBXGroup; + children = ( + CB16E6D7285B7A2B00FD6F52 /* FakeConfirmPaymentResponse.swift */, + CB16E6D9285B7B7300FD6F52 /* MockCardDelegate.swift */, + 3B109B3A2A85B54800D8135F /* MockCardVaultDelegate.swift */, + 808E3EDC2A981F240017FE46 /* MockCheckoutOrdersAPI.swift */, + 808E3EDE2A981F390017FE46 /* MockVaultPaymentTokensAPI.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 80B96AAD2A9806B300C62916 /* Mocks */ = { + isa = PBXGroup; + children = ( + 802EFBD72A9685DF00AB709D /* MockTrackingEventsAPI.swift */, + ); + path = Mocks; + sourceTree = ""; + }; 80B9F85226B8750000D67843 /* CorePayments */ = { isa = PBXGroup; children = ( @@ -512,7 +548,6 @@ 06CE009826F3D1660000CC46 /* CoreConfig.swift */, 065A4DBB26FCD8080007014A /* CoreSDKError.swift */, BEA100E526EF9EDA0036A6A5 /* Networking */, - 807C5E66291027D400ECECD8 /* AnalyticsEventRequest.swift */, 807C5E6829102D9800ECECD8 /* AnalyticsEventData.swift */, 8021B68F29144E6D000FBC54 /* PayPalCoreConstants.swift */, 80DB2F752980795D00CFB86A /* CorePaymentsError.swift */, @@ -543,10 +578,12 @@ 80DBC9D829C336D500462539 /* APIRequests */ = { isa = PBXGroup; children = ( - 3B79E4F62A8503C900C01D06 /* UpdateSetupTokenQuery.swift */, + 3B79E4F62A8503C900C01D06 /* UpdateVaultVariables.swift */, 06CE009F26F3DF100000CC46 /* ConfirmPaymentSourceRequest.swift */, 06CE00A226F3E32A0000CC46 /* ConfirmPaymentSourceResponse.swift */, + 80E2FDBD2A83528B0045593D /* CheckoutOrdersAPI.swift */, 3BD82DBA2A835AF900CBE764 /* UpdateSetupTokenResponse.swift */, + 80B8B2FB2A8EBBFD00AB60CD /* VaultPaymentTokensAPI.swift */, ); path = APIRequests; sourceTree = ""; @@ -556,12 +593,12 @@ children = ( 3B80D50B2A27979000D2EAC4 /* FailingJSONEncoder.swift */, 80E743FF270E40F300BACECA /* FakeRequests.swift */, - BCE8A7F427EA544000AC301B /* MockViewController.swift */, - BCE8A7F627EA54A000AC301B /* MockWebAuthenticationSession.swift */, - 803C31B8270E4C560067D36E /* MockURLSession.swift */, - CB4BE28928512A9800EA2DD1 /* MockQuededURLSession.swift */, E64763702899B60C00074113 /* MockAPIClient.swift */, 802C4A732945670400896A5D /* MockHTTP.swift */, + CB4BE28928512A9800EA2DD1 /* MockQuededURLSession.swift */, + 803C31B8270E4C560067D36E /* MockURLSession.swift */, + BCE8A7F427EA544000AC301B /* MockViewController.swift */, + BCE8A7F627EA54A000AC301B /* MockWebAuthenticationSession.swift */, ); path = TestShared; sourceTree = ""; @@ -584,11 +621,10 @@ isa = PBXGroup; children = ( 065A4DBE26FCDA5B0007014A /* CardClient_Tests.swift */, + 8008D2042A9E54FF0003CAF4 /* CheckoutOrdersAPI_Tests.swift */, 065A4DC226FCE1D20007014A /* ConfirmPaymentSourceRequest_Tests.swift */, - CB16E6D7285B7A2B00FD6F52 /* CardResponses.swift */, - CB16E6D9285B7B7300FD6F52 /* MockCardDelegate.swift */, - 3B109B382A85A9DB00D8135F /* MockGraphQLClient.swift */, - 3B109B3A2A85B54800D8135F /* MockCardVaultDelegate.swift */, + 80B27AF02A9E9EE60008EA45 /* VaultPaymentTokensAPI_Tests.swift */, + 808E3EDB2A981F130017FE46 /* Mocks */, ); path = CardPaymentsTests; sourceTree = ""; @@ -692,15 +728,17 @@ E646231928369B71008AC8E1 /* GraphQL */, BEA100E626EF9EDA0036A6A5 /* APIClient.swift */, 804E628529380B04004B9FEF /* AnalyticsService.swift */, + 80E2FDBF2A8353550045593D /* TrackingEventsAPI.swift */, 804E62812937EBCE004B9FEF /* HTTP.swift */, + 80E237DE2A84434B00FF18CA /* HTTPRequest.swift */, 80E643822A1EBBD2008FD705 /* HTTPResponse.swift */, + 80E2FDC22A8354AD0045593D /* RESTRequest.swift */, 807BF58E2A2A5D19002F32B3 /* HTTPResponseParser.swift */, BEA100EF26EFA7C20036A6A5 /* APIClientError.swift */, BC04836E27B2FB3600FA7B46 /* URLSessionProtocol.swift */, BC04837327B2FC7300FA7B46 /* URLSession+URLSessionProtocol.swift */, BEA100EA26EFA7670036A6A5 /* Enums */, 06E406D126EFE7B3002533B1 /* Models */, - BEA100F526EFB4A90036A6A5 /* Protocols */, ); path = Networking; sourceTree = ""; @@ -715,21 +753,13 @@ path = Enums; sourceTree = ""; }; - BEA100F526EFB4A90036A6A5 /* Protocols */ = { - isa = PBXGroup; - children = ( - BEA100E826EFA1EE0036A6A5 /* APIRequest.swift */, - ); - path = Protocols; - sourceTree = ""; - }; E646231928369B71008AC8E1 /* GraphQL */ = { isa = PBXGroup; children = ( - E64623372836AFC1008AC8E1 /* GraphQLClient.swift */, - E64623262836A81E008AC8E1 /* GraphQLError.swift */, - E646231B28369B9B008AC8E1 /* GraphQLQuery.swift */, - E64623222836A69E008AC8E1 /* GraphQLQueryResponse.swift */, + E64623222836A69E008AC8E1 /* GraphQLHTTPResponse.swift */, + 807D56AB2A869044009E591D /* GraphQLHTTPPostBody.swift */, + 807D56AD2A869064009E591D /* GraphQLRequest.swift */, + 807D56AF2A869F97009E591D /* GraphQLErrorResponse.swift */, ); path = GraphQL; sourceTree = ""; @@ -1251,10 +1281,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - E699EC16285A388E0044A753 /* GraphQLClient_Tests.swift in Sources */, - 808EEA81291321FE001B6765 /* AnalyticsEventRequest_Tests.swift in Sources */, + 808EEA81291321FE001B6765 /* AnalyticsEventData_Tests.swift in Sources */, 8036C1E5270F9BE700C0F091 /* Environment_Tests.swift in Sources */, + 80B96AAE2A980F6B00C62916 /* MockTrackingEventsAPI.swift in Sources */, 80FC261D29847AC7008EC841 /* HTTP_Tests.swift in Sources */, + 802EFBDB2A96B47A00AB709D /* TrackingEventsAPI_Tests.swift in Sources */, 802C4A762945676E00896A5D /* AnalyticsService_Tests.swift in Sources */, 807BF5912A2A5D48002F32B3 /* HTTPResponseParser_Tests.swift in Sources */, 8036C1E4270F9BE700C0F091 /* APIClient_Tests.swift in Sources */, @@ -1265,30 +1296,31 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 807C5E67291027D400ECECD8 /* AnalyticsEventRequest.swift in Sources */, - E6022E802857C6BE008B0E27 /* GraphQLQueryResponse.swift in Sources */, + E6022E802857C6BE008B0E27 /* GraphQLHTTPResponse.swift in Sources */, 80E643832A1EBBD2008FD705 /* HTTPResponse.swift in Sources */, 807C5E6929102D9800ECECD8 /* AnalyticsEventData.swift in Sources */, - E6022E822857C6BE008B0E27 /* GraphQLError.swift in Sources */, - E6022E832857C6BE008B0E27 /* GraphQLQuery.swift in Sources */, - E6022E842857C6BE008B0E27 /* GraphQLClient.swift in Sources */, + 80E237DF2A84434B00FF18CA /* HTTPRequest.swift in Sources */, 8021B69029144E6D000FBC54 /* PayPalCoreConstants.swift in Sources */, + 807D56B02A869F97009E591D /* GraphQLErrorResponse.swift in Sources */, 80DB2F762980795D00CFB86A /* CorePaymentsError.swift in Sources */, 06CE009926F3D1660000CC46 /* CoreConfig.swift in Sources */, BEA100EC26EFA7790036A6A5 /* HTTPMethod.swift in Sources */, BEA100EE26EFA7990036A6A5 /* HTTPHeader.swift in Sources */, CB4BE27E2847EA7D00EA2DD1 /* WebAuthenticationSession.swift in Sources */, + 80E2FDC12A83535A0045593D /* TrackingEventsAPI.swift in Sources */, BEA100F226EFA7DE0036A6A5 /* Environment.swift in Sources */, BEA100F026EFA7C20036A6A5 /* APIClientError.swift in Sources */, 804E628629380B04004B9FEF /* AnalyticsService.swift in Sources */, - BEA100E926EFA1EE0036A6A5 /* APIRequest.swift in Sources */, BC04837427B2FC7300FA7B46 /* URLSession+URLSessionProtocol.swift in Sources */, + 80E2FDC32A8354AD0045593D /* RESTRequest.swift in Sources */, 3DC42BA927187E8300B71645 /* ErrorResponse.swift in Sources */, + 807D56AC2A869044009E591D /* GraphQLHTTPPostBody.swift in Sources */, 804E62822937EBCE004B9FEF /* HTTP.swift in Sources */, BC04836F27B2FB3600FA7B46 /* URLSessionProtocol.swift in Sources */, 065A4DBC26FCD8090007014A /* CoreSDKError.swift in Sources */, BEA100E726EF9EDA0036A6A5 /* APIClient.swift in Sources */, 807BF58F2A2A5D19002F32B3 /* HTTPResponseParser.swift in Sources */, + 807D56AE2A869064009E591D /* GraphQLRequest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1330,7 +1362,8 @@ buildActionMask = 2147483647; files = ( 06CE009B26F3D5A40000CC46 /* CardClient.swift in Sources */, - 3B79E4F72A8503CA00C01D06 /* UpdateSetupTokenQuery.swift in Sources */, + 80E2FDBE2A83528B0045593D /* CheckoutOrdersAPI.swift in Sources */, + 3B79E4F72A8503CA00C01D06 /* UpdateVaultVariables.swift in Sources */, 8048D28C270B9DE00072214A /* ConfirmPaymentSourceResponse.swift in Sources */, 3B109B3D2A85D1CA00D8135F /* MockGraphQLClient.swift in Sources */, 3D1763A22720722A00652E1C /* CardResult.swift in Sources */, @@ -1343,6 +1376,7 @@ 80DCC59E2719DB6F00EC7C5A /* CardClientError.swift in Sources */, 3B22E8B62A840ECF00962E34 /* CardVaultDelegate.swift in Sources */, 3BDB34942A80CE6E008100D7 /* CardVaultRequest.swift in Sources */, + 80B8B2FC2A8EBBFD00AB60CD /* VaultPaymentTokensAPI.swift in Sources */, CB4BE27D2847AF6F00EA2DD1 /* SCA.swift in Sources */, 0647E70E2714962800F8E517 /* Address.swift in Sources */, BC7F8123275FC1350011EDC8 /* CardRequest.swift in Sources */, @@ -1354,10 +1388,14 @@ buildActionMask = 2147483647; files = ( 065A4DBF26FCDA5B0007014A /* CardClient_Tests.swift in Sources */, + 8008D2052A9E54FF0003CAF4 /* CheckoutOrdersAPI_Tests.swift in Sources */, 3B109B3C2A85CC6200D8135F /* MockCardVaultDelegate.swift in Sources */, - CB16E6D8285B7A2B00FD6F52 /* CardResponses.swift in Sources */, + 80B27AF12A9E9EE60008EA45 /* VaultPaymentTokensAPI_Tests.swift in Sources */, + CB16E6D8285B7A2B00FD6F52 /* FakeConfirmPaymentResponse.swift in Sources */, CB16E6DA285B7B7300FD6F52 /* MockCardDelegate.swift in Sources */, BC0A82A6270B9954006E9A21 /* ConfirmPaymentSourceRequest_Tests.swift in Sources */, + 808E3EDD2A981F240017FE46 /* MockCheckoutOrdersAPI.swift in Sources */, + 808E3EDF2A981F390017FE46 /* MockVaultPaymentTokensAPI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/CardPayments/APIRequests/CheckoutOrdersAPI.swift b/Sources/CardPayments/APIRequests/CheckoutOrdersAPI.swift new file mode 100644 index 000000000..0bb60f803 --- /dev/null +++ b/Sources/CardPayments/APIRequests/CheckoutOrdersAPI.swift @@ -0,0 +1,44 @@ +import Foundation +#if canImport(CorePayments) +import CorePayments +#endif + +/// This class coordinates networking logic for communicating with the v2/checkout/orders API. +/// +/// Details on this PayPal API can be found in PPaaS under Merchant > Checkout > Orders > v2. +class CheckoutOrdersAPI { + + // MARK: - Private Propertires + + private let coreConfig: CoreConfig + private let apiClient: APIClient + + // MARK: - Initializer + + init(coreConfig: CoreConfig) { + self.coreConfig = coreConfig + self.apiClient = APIClient(coreConfig: coreConfig) + } + + /// Exposed for injecting MockAPIClient in tests + init(coreConfig: CoreConfig, apiClient: APIClient) { + self.coreConfig = coreConfig + self.apiClient = apiClient + } + + // MARK: - Internal Methods + + func confirmPaymentSource(cardRequest: CardRequest) async throws -> ConfirmPaymentSourceResponse { + let confirmData = ConfirmPaymentSourceRequest(cardRequest: cardRequest) + + let restRequest = RESTRequest( + path: "/v2/checkout/orders/\(cardRequest.orderID)/confirm-payment-source", + method: .post, + queryParameters: nil, + postParameters: confirmData + ) + + let httpResponse = try await apiClient.fetch(request: restRequest) + return try HTTPResponseParser().parseREST(httpResponse, as: ConfirmPaymentSourceResponse.self) + } +} diff --git a/Sources/CardPayments/APIRequests/ConfirmPaymentSourceRequest.swift b/Sources/CardPayments/APIRequests/ConfirmPaymentSourceRequest.swift index 015f3e72b..68e0f2790 100644 --- a/Sources/CardPayments/APIRequests/ConfirmPaymentSourceRequest.swift +++ b/Sources/CardPayments/APIRequests/ConfirmPaymentSourceRequest.swift @@ -4,132 +4,90 @@ import CorePayments #endif /// Describes request to confirm a payment source (approve an order) -struct ConfirmPaymentSourceRequest: APIRequest { +struct ConfirmPaymentSourceRequest: Encodable { - private let orderID: String - private let pathFormat: String = "/v2/checkout/orders/%@/confirm-payment-source" - private let base64EncodedCredentials: String - var jsonEncoder: JSONEncoder + // MARK: - Coding Keys - /// Creates a request to attach a payment source to a specific order. - /// In order to use this initializer, the `paymentSource` parameter has to - /// contain the entire dictionary as it exists underneath the `payment_source` key. - init( - clientID: String, - cardRequest: CardRequest, - encoder: JSONEncoder = JSONEncoder() // exposed for test injection - ) throws { - self.jsonEncoder = encoder - var confirmPaymentSource = ConfirmPaymentSource() - - confirmPaymentSource.applicationContext = ApplicationContext( - returnUrl: PayPalCoreConstants.callbackURLScheme + "://card/success", - cancelUrl: PayPalCoreConstants.callbackURLScheme + "://card/cancel" - ) - - confirmPaymentSource.paymentSource = PaymentSource( - card: cardRequest.card, - scaType: cardRequest.sca - ) - - self.orderID = cardRequest.orderID - self.base64EncodedCredentials = Data(clientID.appending(":").utf8).base64EncodedString() - path = String(format: pathFormat, orderID) - - jsonEncoder.keyEncodingStrategy = .convertToSnakeCase - do { - body = try jsonEncoder.encode(confirmPaymentSource) - } catch { - throw CardClientError.encodingError - } - - // TODO - The complexity in this `init` signals to reconsider our use/design of the `APIRequest` protocol. - // Existing pattern doesn't provide clear, testable interface for encoding JSON POST bodies. + private enum TopLevelKeys: String, CodingKey { + case paymentSource = "payment_source" + case applicationContext = "application_context" } - // MARK: - APIRequest - - typealias ResponseType = ConfirmPaymentSourceResponse - - var path: String - var method: HTTPMethod = .post - var body: Data? - - var headers: [HTTPHeader: String] { - [ - .contentType: "application/json", .acceptLanguage: "en_US", - .authorization: "Basic \(base64EncodedCredentials)" - ] + private enum ApplicationContextKeys: String, CodingKey { + case returnURL = "return_url" + case cancelURL = "cancel_url" } - private struct ConfirmPaymentSource: Encodable { - - var paymentSource: PaymentSource? - var applicationContext: ApplicationContext? + private enum PaymentSourceKeys: String, CodingKey { + case card } - private struct ApplicationContext: Encodable { - - let returnUrl: String - let cancelUrl: String - } - - enum CodingKeys: String, CodingKey { + private enum CardKeys: String, CodingKey { case number case expiry - case securityCode - case name case billingAddress + case name case attributes } - private struct EncodedCard: Encodable { - - let number: String - let securityCode: String - let billingAddress: Address? - let name: String? - let expiry: String - let attributes: Attributes? - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(number, forKey: .number) - try container.encode(expiry, forKey: .expiry) - try container.encode(securityCode, forKey: .securityCode) - try container.encode(name, forKey: .name) - try container.encode(billingAddress, forKey: .billingAddress) - try container.encode(attributes, forKey: .attributes) - } + private enum BillingAddressKeys: String, CodingKey { + case addressLine1 = "address_line_1" + case addressLine2 = "address_line_2" + case adminArea1 = "admin_area_1" + case adminArea2 = "admin_area_2" + case postalCode + case countryCode } - private struct Verification: Encodable { - - let method: String + private enum AttributesKeys: String, CodingKey { + case vault + case verification } - private struct Attributes: Encodable { - - let verification: Verification - - init(verificationMethod: String) { - self.verification = Verification(method: verificationMethod) - } + private enum VerificationKeys: String, CodingKey { + case method + } + + // MARK: - Internal Properties + // Exposed for testing + + let returnURL = PayPalCoreConstants.callbackURLScheme + "://card/success" + let cancelURL = PayPalCoreConstants.callbackURLScheme + "://card/cancel" + let cardRequest: CardRequest + + // MARK: - Initializer + + init(cardRequest: CardRequest) { + self.cardRequest = cardRequest } - private struct PaymentSource: Encodable { + // MARK: - Custom Encoder + + func encode(to encoder: Encoder) throws { + var topLevel = encoder.container(keyedBy: TopLevelKeys.self) - let card: EncodedCard + var applicationContext = topLevel.nestedContainer(keyedBy: ApplicationContextKeys.self, forKey: .applicationContext) + try applicationContext.encode(returnURL, forKey: .returnURL) + try applicationContext.encode(cancelURL, forKey: .cancelURL) - init(card: Card, scaType: SCA) { - self.card = EncodedCard( - number: card.number, - securityCode: card.securityCode, - billingAddress: card.billingAddress, - name: card.cardholderName, - expiry: "\(card.expirationYear)-\(card.expirationMonth)", - attributes: Attributes(verificationMethod: scaType.rawValue) - ) + var paymentSource = topLevel.nestedContainer(keyedBy: PaymentSourceKeys.self, forKey: .paymentSource) + var card = paymentSource.nestedContainer(keyedBy: CardKeys.self, forKey: .card) + try card.encode(cardRequest.card.number, forKey: .number) + try card.encode("\(cardRequest.card.expirationYear)-\(cardRequest.card.expirationMonth)", forKey: .expiry) + try card.encodeIfPresent(cardRequest.card.cardholderName, forKey: .name) + + if let cardBillingInfo = cardRequest.card.billingAddress { + var billingAddress = card.nestedContainer(keyedBy: BillingAddressKeys.self, forKey: .billingAddress) + try billingAddress.encodeIfPresent(cardBillingInfo.addressLine1, forKey: .addressLine1) + try billingAddress.encodeIfPresent(cardBillingInfo.addressLine2, forKey: .addressLine2) + try billingAddress.encodeIfPresent(cardBillingInfo.postalCode, forKey: .postalCode) + try billingAddress.encodeIfPresent(cardBillingInfo.region, forKey: .adminArea1) + try billingAddress.encodeIfPresent(cardBillingInfo.locality, forKey: .adminArea2) + try billingAddress.encodeIfPresent(cardBillingInfo.countryCode, forKey: .countryCode) } + + var attributes = card.nestedContainer(keyedBy: AttributesKeys.self, forKey: .attributes) + var verification = attributes.nestedContainer(keyedBy: VerificationKeys.self, forKey: .verification) + try verification.encode(cardRequest.sca.rawValue, forKey: .method) } } diff --git a/Sources/CardPayments/APIRequests/UpdateSetupTokenQuery.swift b/Sources/CardPayments/APIRequests/UpdateSetupTokenQuery.swift deleted file mode 100644 index 043e99443..000000000 --- a/Sources/CardPayments/APIRequests/UpdateSetupTokenQuery.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -#if canImport(CorePayments) -import CorePayments -#endif - -class UpdateSetupTokenQuery: Codable, GraphQLQuery { - - struct VaultCard: Codable { - - public let number: String - public let expiry: String - public let securityCode: String - public let name: String? - public let billingAddress: Address? - - init(number: String, expiry: String, securityCode: String, name: String? = nil, billingAddress: Address? = nil) { - self.number = number - self.expiry = expiry - self.securityCode = securityCode - self.name = name - self.billingAddress = billingAddress - } - } - - struct PaymentSource: Codable { - - let card: VaultCard - } - - struct Variables: Codable { - - let clientID: String - let vaultSetupToken: String - let paymentSource: PaymentSource - } - - var query: String - var variables: Variables? - - init( - clientID: String, - vaultSetupToken: String, - card: Card - ) { - let vaultCard = VaultCard( - number: card.number, - expiry: card.expiry, - securityCode: card.securityCode, - name: card.cardholderName, - billingAddress: card.billingAddress - ) - - let paymentSource = PaymentSource(card: vaultCard) - - self.variables = Variables( - clientID: clientID, - vaultSetupToken: vaultSetupToken, - paymentSource: paymentSource - ) - // swiftlint:disable indentation_width - let queryString = """ - mutation UpdateVaultSetupToken( - $clientID: String!, - $vaultSetupToken: String!, - $paymentSource: PaymentSource - ) { - updateVaultSetupToken( - clientId: $clientID - vaultSetupToken: $vaultSetupToken - paymentSource: $paymentSource - ) { - id, - status, - links { - rel, href - } - } - } - """ - self.query = queryString - } - // swiftlint:enable indentation_width -} diff --git a/Sources/CardPayments/APIRequests/UpdateVaultVariables.swift b/Sources/CardPayments/APIRequests/UpdateVaultVariables.swift new file mode 100644 index 000000000..9af829ca8 --- /dev/null +++ b/Sources/CardPayments/APIRequests/UpdateVaultVariables.swift @@ -0,0 +1,52 @@ +import Foundation + +struct UpdateVaultVariables: Encodable { + + // MARK: - Coding Keys + + private enum TopLevelKeys: String, CodingKey { + case clientID + case vaultSetupToken + case paymentSource + } + + private enum PaymentSourceKeys: String, CodingKey { + case card + } + + private enum CardKeys: String, CodingKey { + case number + case securityCode + case expiry + case name + } + + // MARK: - Internal Properties + // Exposed for testing + + let vaultRequest: CardVaultRequest + let clientID: String + + // MARK: - Initializer + + init(cardVaultRequest: CardVaultRequest, clientID: String) { + self.vaultRequest = cardVaultRequest + self.clientID = clientID + } + + // MARK: - Custom Encoder + + func encode(to encoder: Encoder) throws { + var topLevel = encoder.container(keyedBy: TopLevelKeys.self) + try topLevel.encode(clientID, forKey: .clientID) + try topLevel.encode(vaultRequest.setupTokenID, forKey: .vaultSetupToken) + + var paymentSource = topLevel.nestedContainer(keyedBy: PaymentSourceKeys.self, forKey: .paymentSource) + + var card = paymentSource.nestedContainer(keyedBy: CardKeys.self, forKey: .card) + try card.encode(vaultRequest.card.number, forKey: .number) + try card.encode(vaultRequest.card.securityCode, forKey: .securityCode) + try card.encode(vaultRequest.card.expiry, forKey: .expiry) + try card.encodeIfPresent(vaultRequest.card.cardholderName, forKey: .name) + } +} diff --git a/Sources/CardPayments/APIRequests/VaultPaymentTokensAPI.swift b/Sources/CardPayments/APIRequests/VaultPaymentTokensAPI.swift new file mode 100644 index 000000000..656f4eb37 --- /dev/null +++ b/Sources/CardPayments/APIRequests/VaultPaymentTokensAPI.swift @@ -0,0 +1,64 @@ +import Foundation +#if canImport(CorePayments) +import CorePayments +#endif + +/// This class coordinates networking logic for communicating with the /graphql?UpdateVaultSetupToken API. +class VaultPaymentTokensAPI { + + // MARK: - Private Propertires + + private let coreConfig: CoreConfig + private let apiClient: APIClient + + // MARK: - Initializer + + init(coreConfig: CoreConfig) { + self.coreConfig = coreConfig + self.apiClient = APIClient(coreConfig: coreConfig) + } + + /// Exposed for injecting MockAPIClient in tests + init(coreConfig: CoreConfig, apiClient: APIClient) { + self.coreConfig = coreConfig + self.apiClient = apiClient + } + + // MARK: - Internal Methods + + func updateSetupToken(cardVaultRequest: CardVaultRequest) async throws -> UpdateSetupTokenResponse { + + let queryString = """ + mutation UpdateVaultSetupToken( + $clientID: String!, + $vaultSetupToken: String!, + $paymentSource: PaymentSource + ) { + updateVaultSetupToken( + clientId: $clientID + vaultSetupToken: $vaultSetupToken + paymentSource: $paymentSource + ) { + id, + status, + links { + rel, + href + } + } + } + """ + + let variables = UpdateVaultVariables(cardVaultRequest: cardVaultRequest, clientID: coreConfig.clientID) + + let graphQLRequest = GraphQLRequest( + query: queryString, + variables: variables, + queryNameForURL: "UpdateVaultSetupToken" + ) + + let httpResponse = try await apiClient.fetch(request: graphQLRequest) + + return try HTTPResponseParser().parseGraphQL(httpResponse, as: UpdateSetupTokenResponse.self) + } +} diff --git a/Sources/CardPayments/CardClient.swift b/Sources/CardPayments/CardClient.swift index e03ae36fa..2d30ca62c 100644 --- a/Sources/CardPayments/CardClient.swift +++ b/Sources/CardPayments/CardClient.swift @@ -9,42 +9,42 @@ public class CardClient: NSObject { public weak var delegate: CardDelegate? public weak var vaultDelegate: CardVaultDelegate? - private let apiClient: APIClient + private let checkoutOrdersAPI: CheckoutOrdersAPI + private let vaultAPI: VaultPaymentTokensAPI + private let config: CoreConfig private let webAuthenticationSession: WebAuthenticationSession private var analyticsService: AnalyticsService? - private var graphQLClient: GraphQLClient? /// Initialize a CardClient to process card payment /// - Parameter config: The CoreConfig object public init(config: CoreConfig) { self.config = config - self.apiClient = APIClient(coreConfig: config) + self.checkoutOrdersAPI = CheckoutOrdersAPI(coreConfig: config) + self.vaultAPI = VaultPaymentTokensAPI(coreConfig: config) self.webAuthenticationSession = WebAuthenticationSession() - self.graphQLClient = GraphQLClient(environment: config.environment) } /// For internal use for testing/mocking purpose init( config: CoreConfig, - apiClient: APIClient, - webAuthenticationSession: WebAuthenticationSession, - graphQLClient: GraphQLClient? = nil + checkoutOrdersAPI: CheckoutOrdersAPI, + vaultAPI: VaultPaymentTokensAPI, + webAuthenticationSession: WebAuthenticationSession ) { self.config = config - self.apiClient = apiClient + self.checkoutOrdersAPI = checkoutOrdersAPI + self.vaultAPI = vaultAPI self.webAuthenticationSession = webAuthenticationSession - self.graphQLClient = graphQLClient } - + public func vault(_ vaultRequest: CardVaultRequest) { Task { do { - let card = vaultRequest.card - let setupTokenID = vaultRequest.setupTokenID - let result = try await updateSetupToken(vaultSetupTokenID: setupTokenID, card: card) - // TODO: handle 3DS contingency with helios link - if let link = result.links.first(where: { $0.rel == "approve" && $0.href.contains("helios") }) { + let result = try await vaultAPI.updateSetupToken(cardVaultRequest: vaultRequest).updateVaultSetupToken + + // TODO: handle 3DS contingency with helios link & add unit tests + if let link = result.links.first(where: { $0.rel == "approve" && $0.href.contains("helios") }) { let url = link.href print("3DS url \(url)") } else { @@ -58,23 +58,6 @@ public class CardClient: NSObject { } } } - - func updateSetupToken(vaultSetupTokenID: String, card: Card) async throws -> TokenDetails { - guard let graphQLClient else { - throw CardClientError.nilGraphQLClientError - } - - let clientID = config.clientID - let query = UpdateSetupTokenQuery(clientID: clientID, vaultSetupToken: vaultSetupTokenID, card: card) - let response: GraphQLQueryResponse = try await graphQLClient.callGraphQL( - name: "UpdateVaultSetupToken", query: query - ) - guard let data = response.data else { - throw CardClientError.noVaultTokenDataError - } - - return data.updateVaultSetupToken - } /// Approve an order with a card, which validates buyer's card, and if valid, attaches the card as the payment source to the order. /// After the order has been successfully approved, you will need to handle capturing/authorizing the order in your server. @@ -88,8 +71,7 @@ public class CardClient: NSObject { analyticsService?.sendEvent("card-payments:3ds:started") Task { do { - let confirmPaymentRequest = try ConfirmPaymentSourceRequest(clientID: config.clientID, cardRequest: request) - let (result) = try await apiClient.fetch(request: confirmPaymentRequest) + let result = try await checkoutOrdersAPI.confirmPaymentSource(cardRequest: request) if let url: String = result.links?.first(where: { $0.rel == "payer-action" })?.href { analyticsService?.sendEvent("card-payments:3ds:confirm-payment-source:challenge-required") diff --git a/Sources/CorePayments/AnalyticsEventRequest.swift b/Sources/CorePayments/AnalyticsEventRequest.swift deleted file mode 100644 index 01b026478..000000000 --- a/Sources/CorePayments/AnalyticsEventRequest.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -struct AnalyticsEventRequest: APIRequest { - - init(eventData: AnalyticsEventData) throws { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - body = try encoder.encode(eventData) - } - - // MARK: - APIRequest conformance - - typealias ResponseType = EmptyResponse - - var path = "v1/tracking/events" - var method: HTTPMethod = .post - var headers: [HTTPHeader: String] = [.contentType: "application/json"] - var body: Data? - - // api.sandbox.paypal.com does not currently send FPTI events to BigQuery/Looker - func toURLRequest(environment: Environment) -> URLRequest? { - composeURLRequest(environment: .live) - } -} - -struct EmptyResponse: Decodable { } diff --git a/Sources/CorePayments/CorePaymentsError.swift b/Sources/CorePayments/CorePaymentsError.swift index 779455a7b..d931ab072 100644 --- a/Sources/CorePayments/CorePaymentsError.swift +++ b/Sources/CorePayments/CorePaymentsError.swift @@ -6,13 +6,13 @@ public enum CorePaymentsError { /// 0. An unknown error occurred. case unknown - /// 1. Error returned from the ClientID service - case clientIDNotFoundError + /// 1. An error occured constructing the PayPal API URL + case urlEncodingFailed } - public static let clientIDNotFoundError = CoreSDKError( - code: Code.clientIDNotFoundError.rawValue, + public static let urlEncodingFailed = CoreSDKError( + code: Code.urlEncodingFailed.rawValue, domain: domain, - errorDescription: "Error fetching clientID. Contact developer.paypal.com/support." + errorDescription: "An error occured constructing the PayPal API URL. Contact developer.paypal.com/support." ) } diff --git a/Sources/CorePayments/Networking/APIClient.swift b/Sources/CorePayments/Networking/APIClient.swift index 10d9a0446..bd92d96aa 100644 --- a/Sources/CorePayments/Networking/APIClient.swift +++ b/Sources/CorePayments/Networking/APIClient.swift @@ -1,5 +1,6 @@ import Foundation +// TODO: - Rename to `NetworkingClient`. Now that we have `API.swift` classes, ths responsibility of this class really is to coordinate networking. It transforms REST & GraphQL into HTTP requests. /// :nodoc: This method is exposed for internal PayPal use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. /// /// `APIClient` is the entry point for each payment method feature to perform API requests. It also offers convenience methods for API requests used across multiple payment methods / modules. @@ -27,9 +28,81 @@ public class APIClient { // MARK: - Public Methods - /// :nodoc: This method is exposed for internal PayPal use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. - public func fetch(request: T) async throws -> (T.ResponseType) { - let httpResponse = try await http.performRequest(request) - return try HTTPResponseParser().parse(httpResponse, as: T.ResponseType.self) + /// :nodoc: + public func fetch(request: RESTRequest) async throws -> HTTPResponse { + let url = try constructRESTURL(path: request.path, queryParameters: request.queryParameters) + + let base64EncodedCredentials = Data(coreConfig.clientID.appending(":").utf8).base64EncodedString() + var headers: [HTTPHeader: String] = [ + .authorization: "Basic \(base64EncodedCredentials)" + ] + if request.method == .post { + headers[.contentType] = "application/json" + } + + // TODO: - Move JSON encoding into custom class, similar to HTTPResponseParser + var data: Data? + if let postBody = request.postParameters { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + data = try encoder.encode(postBody) + } + + let httpRequest = HTTPRequest(headers: headers, method: request.method, url: url, body: data) + + return try await http.performRequest(httpRequest) + } + + /// :nodoc: + public func fetch(request: GraphQLRequest) async throws -> HTTPResponse { + let url = try constructGraphQLURL(queryName: request.queryNameForURL) + + // TODO: - Move JSON encoding into custom class + let postBody = GraphQLHTTPPostBody(query: request.query, variables: request.variables) + // TODO: - encoding `Data` results in mumbo jumbo string. Why + let postData = try JSONEncoder().encode(postBody) + + let httpRequest = HTTPRequest( + headers: [ + .contentType: "application/json", + .accept: "application/json", + .appName: "ppcpmobilesdk", + .origin: coreConfig.environment.graphQLURL.absoluteString + ], + method: .post, + url: url, + body: postData + ) + + return try await http.performRequest(httpRequest) + } + + // MARK: - Private Methods + + private func constructRESTURL(path: String, queryParameters: [String: String]?) throws -> URL { + let urlString = coreConfig.environment.baseURL.appendingPathComponent(path) + var urlComponents = URLComponents(url: urlString, resolvingAgainstBaseURL: false) + + if let queryParameters { + urlComponents?.queryItems = queryParameters.map { URLQueryItem(name: $0.key, value: $0.value) } + } + + guard let url = urlComponents?.url else { + throw CorePaymentsError.urlEncodingFailed + } + + return url + } + + private func constructGraphQLURL(queryName: String? = nil) throws -> URL { + guard let queryName else { + return coreConfig.environment.graphQLURL + } + + guard let url = URL(string: coreConfig.environment.graphQLURL.absoluteString + "?" + queryName) else { + throw CorePaymentsError.urlEncodingFailed + } + + return url } } diff --git a/Sources/CorePayments/Networking/APIClientError.swift b/Sources/CorePayments/Networking/APIClientError.swift index f459b9f0a..94fd4bef4 100644 --- a/Sources/CorePayments/Networking/APIClientError.swift +++ b/Sources/CorePayments/Networking/APIClientError.swift @@ -25,6 +25,9 @@ enum APIClientError { /// 6. The server's response body returned an error message. case serverResponseError + + /// 7. Missing expected GraphQL response data key. + case noGraphQLDataKey } static let unknownError = CoreSDKError( @@ -74,4 +77,10 @@ enum APIClientError { errorDescription: description ) } + + static let noGraphQLDataKey = CoreSDKError( + code: Code.noResponseData.rawValue, + domain: domain, + errorDescription: "An error occured due to missing `data` key in GraphQL query response. Contact developer.paypal.com/support." + ) } diff --git a/Sources/CorePayments/Networking/AnalyticsService.swift b/Sources/CorePayments/Networking/AnalyticsService.swift index 056dd0e97..16b51d4ef 100644 --- a/Sources/CorePayments/Networking/AnalyticsService.swift +++ b/Sources/CorePayments/Networking/AnalyticsService.swift @@ -6,23 +6,23 @@ public struct AnalyticsService { // MARK: - Internal Properties private let coreConfig: CoreConfig - private let http: HTTP + private let trackingEventsAPI: TrackingEventsAPI private let orderID: String // MARK: - Initializer public init(coreConfig: CoreConfig, orderID: String) { self.coreConfig = coreConfig - self.http = HTTP(coreConfig: coreConfig) + self.trackingEventsAPI = TrackingEventsAPI(coreConfig: coreConfig) self.orderID = orderID } // MARK: - Internal Initializer /// Exposed for testing - init(coreConfig: CoreConfig, orderID: String, http: HTTP) { + init(coreConfig: CoreConfig, orderID: String, trackingEventsAPI: TrackingEventsAPI) { self.coreConfig = coreConfig - self.http = http + self.trackingEventsAPI = trackingEventsAPI self.orderID = orderID } @@ -40,19 +40,19 @@ public struct AnalyticsService { // MARK: - Internal Methods + /// Exposed to be able to execute this function synchronously in unit tests func performEventRequest(_ name: String) async { do { let clientID = coreConfig.clientID let eventData = AnalyticsEventData( - environment: http.coreConfig.environment.toString, + environment: coreConfig.environment.toString, eventName: name, clientID: clientID, orderID: orderID ) - let analyticsEventRequest = try AnalyticsEventRequest(eventData: eventData) - let (_) = try await http.performRequest(analyticsEventRequest) + let (_) = try await trackingEventsAPI.sendEvent(with: eventData) } catch { NSLog("[PayPal SDK] Failed to send analytics: %@", error.localizedDescription) } diff --git a/Sources/CorePayments/Networking/Enums/HTTPHeader.swift b/Sources/CorePayments/Networking/Enums/HTTPHeader.swift index 585344432..637bfabef 100644 --- a/Sources/CorePayments/Networking/Enums/HTTPHeader.swift +++ b/Sources/CorePayments/Networking/Enums/HTTPHeader.swift @@ -3,6 +3,8 @@ import Foundation public enum HTTPHeader: String { case accept = "Accept" case acceptLanguage = "Accept-Language" - case contentType = "Content-Type" + case appName = "x-app-name" case authorization = "Authorization" + case contentType = "Content-Type" + case origin = "Origin" } diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLClient.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLClient.swift deleted file mode 100644 index 889762494..000000000 --- a/Sources/CorePayments/Networking/GraphQL/GraphQLClient.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -//// :nodoc: This class handles urlRequests for GraphQL endpoints -public class GraphQLClient { - - public let environment: Environment - public let urlSession: URLSessionProtocol - public let jsonDecoder = JSONDecoder() - - public init(environment: Environment, urlSession: URLSessionProtocol = URLSession.shared) { - self.environment = environment - self.urlSession = urlSession - } - - public func callGraphQL(name: String, query: Q) async throws -> GraphQLQueryResponse { - - var request = try createURLRequest(name: name, requestBody: query.requestBody()) - headers().forEach { key, value in - request.addValue(value, forHTTPHeaderField: key) - } - let (data, response) = try await urlSession.performRequest(with: request) - guard response is HTTPURLResponse else { - return GraphQLQueryResponse(data: nil) - } - let decoded: GraphQLQueryResponse = try parse(data: data) - return decoded - } - - func parse(data: Data) throws -> T { - return try jsonDecoder.decode(T.self, from: data) - } - - func createURLRequest(name: String? = nil, requestBody: Data) throws -> URLRequest { - var urlString = environment.graphQLURL.absoluteString - - if let name { - urlString.append("?\(name)") - } - - guard let url = URL(string: urlString) else { - throw GraphQLError(message: "error fetching url") - } - - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = HTTPMethod.post.rawValue - urlRequest.httpBody = requestBody - return urlRequest - } - - func headers() -> [String: String] { - [ - "Content-type": "application/json", - "Accept": "application/json", - "x-app-name": "northstar", - "Origin": environment.graphQLURL.absoluteString - ] - } -} - -extension GraphQLQuery where Self: Codable { - - /// :nodoc: Converts GraphQLQuery into Data - public func requestBody() throws -> Data { - let encoder = JSONEncoder() - return try encoder.encode(self) - } -} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLError.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLError.swift deleted file mode 100644 index 554a0ad41..000000000 --- a/Sources/CorePayments/Networking/GraphQL/GraphQLError.swift +++ /dev/null @@ -1,15 +0,0 @@ -public struct GraphQLError: Codable, Error { - - let message: String - let extensions: [Extension]? - - init(message: String, extensions: [Extension]? = nil) { - self.message = message - self.extensions = extensions - } -} - -struct Extension: Codable { - - let correlationId: String -} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLErrorResponse.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLErrorResponse.swift new file mode 100644 index 000000000..8205f98bc --- /dev/null +++ b/Sources/CorePayments/Networking/GraphQL/GraphQLErrorResponse.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Used to parse error message details out of GraphQL HTTP response body +struct GraphQLErrorResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case error = "error" + case correlationID = "correlation_id" + } + + let error: String + let correlationID: String? +} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLHTTPPostBody.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLHTTPPostBody.swift new file mode 100644 index 000000000..cbf1fbd2b --- /dev/null +++ b/Sources/CorePayments/Networking/GraphQL/GraphQLHTTPPostBody.swift @@ -0,0 +1,24 @@ +import Foundation + +/// The GraphQL query and variable details encoded to be sent in the POST body of a HTTP request +struct GraphQLHTTPPostBody: Encodable { + + private let query: String + private let variables: Encodable + + enum CodingKeys: CodingKey { + case query + case variables + } + + init(query: String, variables: Encodable) { + self.query = query + self.variables = variables + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.query, forKey: .query) + try container.encode(self.variables, forKey: .variables) + } +} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLHTTPResponse.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLHTTPResponse.swift new file mode 100644 index 000000000..131e00eba --- /dev/null +++ b/Sources/CorePayments/Networking/GraphQL/GraphQLHTTPResponse.swift @@ -0,0 +1,5 @@ +/// :nodoc: Used to decode the HTTP reponse body of GraphQL requests +public struct GraphQLHTTPResponse: Decodable { + + public let data: T? +} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLQuery.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLQuery.swift deleted file mode 100644 index c029e7ee9..000000000 --- a/Sources/CorePayments/Networking/GraphQL/GraphQLQuery.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -//// :nodoc: Protocol for requests made to GraphQL endpoints -public protocol GraphQLQuery { - associatedtype VariablesType: Codable - var query: String { get } - var variables: VariablesType? { get } - func requestBody() throws -> Data -} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLQueryResponse.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLQueryResponse.swift deleted file mode 100644 index 85776f349..000000000 --- a/Sources/CorePayments/Networking/GraphQL/GraphQLQueryResponse.swift +++ /dev/null @@ -1,5 +0,0 @@ -//// :nodoc: Struct to handle nested response from GraphQL response -public struct GraphQLQueryResponse: Codable { - - public let data: T? -} diff --git a/Sources/CorePayments/Networking/GraphQL/GraphQLRequest.swift b/Sources/CorePayments/Networking/GraphQL/GraphQLRequest.swift new file mode 100644 index 000000000..0846e036e --- /dev/null +++ b/Sources/CorePayments/Networking/GraphQL/GraphQLRequest.swift @@ -0,0 +1,18 @@ +import Foundation + +/// :nodoc: Values needed to initiate a GraphQL network request +public struct GraphQLRequest { + + let query: String + let variables: Encodable + + /// This is non-standard in the GraphQL language, but sometimes required by PayPal's GraphQL API. + /// Some requests are sent to `https://www.api.paypal.com/graphql?` + let queryNameForURL: String? + + public init(query: String, variables: Encodable, queryNameForURL: String? = nil) { + self.query = query + self.variables = variables + self.queryNameForURL = queryNameForURL + } +} diff --git a/Sources/CorePayments/Networking/HTTP.swift b/Sources/CorePayments/Networking/HTTP.swift index f882e5ef2..50a4ab351 100644 --- a/Sources/CorePayments/Networking/HTTP.swift +++ b/Sources/CorePayments/Networking/HTTP.swift @@ -1,6 +1,6 @@ import Foundation -/// `HTTP` interfaces directly with `URLSession` to execute network requests. +/// `HTTP` constructs `URLRequest`s and interfaces directly with `URLSession` to execute network requests. class HTTP { let coreConfig: CoreConfig @@ -14,9 +14,13 @@ class HTTP { self.coreConfig = coreConfig } - func performRequest(_ request: any APIRequest) async throws -> HTTPResponse { - guard let urlRequest = request.toURLRequest(environment: coreConfig.environment) else { - throw APIClientError.invalidURLRequestError + func performRequest(_ httpRequest: HTTPRequest) async throws -> HTTPResponse { + var urlRequest = URLRequest(url: httpRequest.url) + urlRequest.httpMethod = httpRequest.method.rawValue + urlRequest.httpBody = httpRequest.body + + httpRequest.headers.forEach { key, value in + urlRequest.addValue(value, forHTTPHeaderField: key.rawValue) } let (data, response) = try await urlSession.performRequest(with: urlRequest) diff --git a/Sources/CorePayments/Networking/HTTPRequest.swift b/Sources/CorePayments/Networking/HTTPRequest.swift new file mode 100644 index 000000000..0716ee8d3 --- /dev/null +++ b/Sources/CorePayments/Networking/HTTPRequest.swift @@ -0,0 +1,23 @@ +import Foundation + +/// :nodoc: Values needed to initiate a HTTP network request +public struct HTTPRequest { + + let headers: [HTTPHeader: String] + let method: HTTPMethod + let url: URL + let body: Data? + + /// :nodoc: + public init( + headers: [HTTPHeader: String], + method: HTTPMethod, + url: URL, + body: Data? + ) { + self.headers = headers + self.method = method + self.url = url + self.body = body + } +} diff --git a/Sources/CorePayments/Networking/HTTPResponse.swift b/Sources/CorePayments/Networking/HTTPResponse.swift index 3d1e5db66..4e81d07f3 100644 --- a/Sources/CorePayments/Networking/HTTPResponse.swift +++ b/Sources/CorePayments/Networking/HTTPResponse.swift @@ -1,6 +1,7 @@ import Foundation -struct HTTPResponse { +/// :nodoc: +public struct HTTPResponse { let status: Int let body: Data? diff --git a/Sources/CorePayments/Networking/HTTPResponseParser.swift b/Sources/CorePayments/Networking/HTTPResponseParser.swift index 1c8868e65..060ae85f8 100644 --- a/Sources/CorePayments/Networking/HTTPResponseParser.swift +++ b/Sources/CorePayments/Networking/HTTPResponseParser.swift @@ -1,33 +1,71 @@ import Foundation -class HTTPResponseParser { +/// :nodoc: +public class HTTPResponseParser { private let decoder: JSONDecoder + + // MARK: - Initializer - init(decoder: JSONDecoder = JSONDecoder()) { // exposed for test injection + public init(decoder: JSONDecoder = JSONDecoder()) { // exposed for test injection self.decoder = decoder decoder.keyDecodingStrategy = .convertFromSnakeCase } - func parse(_ httpResponse: HTTPResponse, as type: T.Type) throws -> T { + // MARK: - Public Methods + + public func parseREST(_ httpResponse: HTTPResponse, as type: T.Type) throws -> T { guard let data = httpResponse.body else { throw APIClientError.noResponseDataError } if httpResponse.isSuccessful { - do { - let decodedData = try decoder.decode(T.self, from: data) - return (decodedData) - } catch { - throw APIClientError.jsonDecodingError(error.localizedDescription) - } + return try parseSuccessResult(data, as: T.self) + } else { + return try parseErrorResult(data, as: T.self) + } + } + + public func parseGraphQL(_ httpResponse: HTTPResponse, as type: T.Type) throws -> T { + guard let data = httpResponse.body else { + throw APIClientError.noResponseDataError + } + + if httpResponse.isSuccessful { + return try parseSuccessResult(data, as: T.self, isGraphQL: true) } else { - do { + return try parseErrorResult(data, as: T.self, isGraphQL: true) + } + } + + // MARK: - Private Methods + + private func parseSuccessResult(_ data: Data, as type: T.Type, isGraphQL: Bool = false) throws -> T { + do { + if isGraphQL { + guard let data = try decoder.decode(GraphQLHTTPResponse.self, from: data).data else { + throw APIClientError.noGraphQLDataKey + } + return data + } else { + return try decoder.decode(T.self, from: data) + } + } catch { + throw APIClientError.jsonDecodingError(error.localizedDescription) + } + } + + private func parseErrorResult(_ data: Data, as type: T.Type, isGraphQL: Bool = false) throws -> T { + do { + if isGraphQL { + let errorData = try decoder.decode(GraphQLErrorResponse.self, from: data) + throw APIClientError.serverResponseError(errorData.error) + } else { let errorData = try decoder.decode(ErrorResponse.self, from: data) throw APIClientError.serverResponseError(errorData.readableDescription) - } catch { - throw APIClientError.jsonDecodingError(error.localizedDescription) } + } catch { + throw APIClientError.jsonDecodingError(error.localizedDescription) } } } diff --git a/Sources/CorePayments/Networking/Protocols/APIRequest.swift b/Sources/CorePayments/Networking/Protocols/APIRequest.swift deleted file mode 100644 index 49f9451ec..000000000 --- a/Sources/CorePayments/Networking/Protocols/APIRequest.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -public protocol APIRequest { - associatedtype ResponseType: Decodable - - var path: String { get } - var method: HTTPMethod { get } - var headers: [HTTPHeader: String] { get } - var queryParameters: [String: String] { get } - var body: Data? { get } - - func toURLRequest(environment: Environment) -> URLRequest? -} - -public extension APIRequest { - - var queryParameters: [String: String] { [:] } - - var body: Data? { nil } - - // Default implementation vends response from helper function - func toURLRequest(environment: Environment) -> URLRequest? { - composeURLRequest(environment: environment) - } - - func composeURLRequest(environment: Environment) -> URLRequest? { - let completeUrl = environment.baseURL.appendingPathComponent(path) - var urlComponents = URLComponents(url: completeUrl, resolvingAgainstBaseURL: false) - - queryParameters.forEach { - urlComponents?.queryItems?.append(URLQueryItem(name: $0.key, value: $0.value)) - } - - guard let url = urlComponents?.url else { - return nil - } - - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.httpBody = body - - headers.forEach { key, value in - request.addValue(value, forHTTPHeaderField: key.rawValue) - } - - return request - } -} diff --git a/Sources/CorePayments/Networking/RESTRequest.swift b/Sources/CorePayments/Networking/RESTRequest.swift new file mode 100644 index 000000000..e7d2350e4 --- /dev/null +++ b/Sources/CorePayments/Networking/RESTRequest.swift @@ -0,0 +1,22 @@ +import Foundation + +/// :nodoc: +public struct RESTRequest { + + var path: String + var method: HTTPMethod + var queryParameters: [String: String]? + var postParameters: Encodable? + + public init( + path: String, + method: HTTPMethod, + queryParameters: [String: String]? = nil, + postParameters: Encodable? = nil + ) { + self.path = path + self.method = method + self.queryParameters = queryParameters + self.postParameters = postParameters + } +} diff --git a/Sources/CorePayments/Networking/TrackingEventsAPI.swift b/Sources/CorePayments/Networking/TrackingEventsAPI.swift new file mode 100644 index 000000000..06d28a4fd --- /dev/null +++ b/Sources/CorePayments/Networking/TrackingEventsAPI.swift @@ -0,0 +1,39 @@ +import Foundation + +/// This class coordinates networking logic for communicating with the v1/tracking/events API. +/// +/// Details on this PayPal API can be found in PPaaS under Infrastructure > Experimentation > Tracking Events > v1. +class TrackingEventsAPI { + + // MARK: - Internal Properties + + var coreConfig: CoreConfig // exposed for testing + private var apiClient: APIClient + + // MARK: - Initializer + + init(coreConfig merchantConfig: CoreConfig) { + // api.sandbox.paypal.com does not currently send FPTI events to BigQuery/Looker + self.coreConfig = CoreConfig(clientID: merchantConfig.clientID, environment: .live) + self.apiClient = APIClient(coreConfig: coreConfig) + } + + /// Exposed for injecting MockAPIClient in tests + init(coreConfig: CoreConfig, apiClient: APIClient) { + self.coreConfig = coreConfig + self.apiClient = apiClient + } + + // MARK: - Internal Functions + + func sendEvent(with analyticsEventData: AnalyticsEventData) async throws -> HTTPResponse { + let restRequest = RESTRequest( + path: "v1/tracking/events", + method: .post, + queryParameters: nil, + postParameters: analyticsEventData + ) + + return try await apiClient.fetch(request: restRequest) + } +} diff --git a/UnitTests/CardPaymentsTests/CardClient_Tests.swift b/UnitTests/CardPaymentsTests/CardClient_Tests.swift index f0d483657..8ad2a27fc 100644 --- a/UnitTests/CardPaymentsTests/CardClient_Tests.swift +++ b/UnitTests/CardPaymentsTests/CardClient_Tests.swift @@ -7,8 +7,6 @@ import AuthenticationServices class CardClient_Tests: XCTestCase { - let mockClientID = "mockClientId" - // MARK: - Helper Properties let card = Card( @@ -17,32 +15,32 @@ class CardClient_Tests: XCTestCase { expirationYear: "2021", securityCode: "123" ) - - let mockWebAuthSession = MockWebAuthenticationSession() + let config = CoreConfig(clientID: "mockClientId", environment: .sandbox) + var cardRequest: CardRequest! - // swiftlint:disable implicitly_unwrapped_optional - var config: CoreConfig! + let mockWebAuthSession = MockWebAuthenticationSession() var mockAPIClient: MockAPIClient! - var cardClient: CardClient! - var cardRequest: CardRequest! - var mockGraphQLClient: MockGraphQLClient! var mockCardVaultDelegate: MockCardVaultDelegate! - // swiftlint:enable implicitly_unwrapped_optional + var mockCheckoutOrdersAPI: MockCheckoutOrdersAPI! + var mockVaultAPI: MockVaultPaymentTokensAPI! + var sut: CardClient! + // MARK: - Test lifecycle override func setUp() { super.setUp() - config = CoreConfig(clientID: mockClientID, environment: .sandbox) mockAPIClient = MockAPIClient(coreConfig: config) cardRequest = CardRequest(orderID: "testOrderId", card: card) - mockGraphQLClient = MockGraphQLClient(environment: .sandbox) - cardClient = CardClient( + mockCheckoutOrdersAPI = MockCheckoutOrdersAPI(coreConfig: config, apiClient: mockAPIClient) + mockVaultAPI = MockVaultPaymentTokensAPI(coreConfig: config, apiClient: mockAPIClient) + + sut = CardClient( config: config, - apiClient: mockAPIClient, - webAuthenticationSession: mockWebAuthSession, - graphQLClient: mockGraphQLClient + checkoutOrdersAPI: mockCheckoutOrdersAPI, + vaultAPI: mockVaultAPI, + webAuthenticationSession: mockWebAuthSession ) } @@ -55,7 +53,7 @@ class CardClient_Tests: XCTestCase { let updateSetupTokenResponse = UpdateSetupTokenResponse( updateVaultSetupToken: TokenDetails(id: setupTokenID, status: vaultStatus, links: [TokenDetails.Link(rel: "df", href: "h")]) ) - mockGraphQLClient.mockSuccessResponse = GraphQLQueryResponse(data: updateSetupTokenResponse) + mockVaultAPI.stubSetupTokenResponse = updateSetupTokenResponse let expectation = expectation(description: "vault completed") let cardVaultDelegate = MockCardVaultDelegate(success: {_, result in @@ -65,40 +63,39 @@ class CardClient_Tests: XCTestCase { }, error: {_, _ in XCTFail("Invoked error() callback. Should invoke success().") }) - cardClient.vaultDelegate = cardVaultDelegate - cardClient.vault(vaultRequest) + sut.vaultDelegate = cardVaultDelegate + sut.vault(vaultRequest) waitForExpectations(timeout: 10) } - func testVault_withNoData_ReturnsError() { + func testVault_whenVaultAPIError_bubblesError() { let setupTokenID = "testToken1" let vaultRequest = CardVaultRequest(card: card, setupTokenID: setupTokenID) - - mockGraphQLClient.mockSuccessResponse = GraphQLQueryResponse(data: nil) - + + mockVaultAPI.stubError = CoreSDKError(code: 123, domain: "fake-domain", errorDescription: "api-error") + let expectation = expectation(description: "vault completed") let cardVaultDelegate = MockCardVaultDelegate(success: {_, _ in XCTFail("Invoked success() callback. Should invoke error().") }, error: {_, error in - XCTAssertEqual(error.domain, CardClientError.domain) - XCTAssertEqual(error.code, CardClientError.Code.noVaultTokenDataError.rawValue) - XCTAssertEqual(error.localizedDescription, "No data was returned from update setup token service.") + XCTAssertEqual(error.domain, "fake-domain") + XCTAssertEqual(error.code, 123) + XCTAssertEqual(error.localizedDescription, "api-error") expectation.fulfill() }) - cardClient.vaultDelegate = cardVaultDelegate - cardClient.vault(vaultRequest) - + sut.vaultDelegate = cardVaultDelegate + sut.vault(vaultRequest) + waitForExpectations(timeout: 10) } - func testVault_whenGraphQLCallFails_returnsError() { + func testVault_whenUnknownError_returnsVaultError() { let setupTokenID = "testToken1" let vaultRequest = CardVaultRequest(card: card, setupTokenID: setupTokenID) - - mockGraphQLClient.mockErrorResponse = GraphQLError( - message: "thee was an error fetching data from GraphQL endpoint", extensions: nil - ) + + mockVaultAPI.stubError = NSError(domain: "some-domain", code: 123, userInfo: [NSLocalizedDescriptionKey: "some-description"]) + let expectation = expectation(description: "vault completed") let cardVaultDelegate = MockCardVaultDelegate(success: {_, _ in XCTFail("Invoked success() callback. Should invoke error().") @@ -108,16 +105,16 @@ class CardClient_Tests: XCTestCase { XCTAssertEqual(error.localizedDescription, "An error occurred while vaulting a card.") expectation.fulfill() }) - cardClient.vaultDelegate = cardVaultDelegate - cardClient.vault(vaultRequest) - + sut.vaultDelegate = cardVaultDelegate + sut.vault(vaultRequest) + waitForExpectations(timeout: 10) } // MARK: - approveOrder() tests - + func testApproveOrder_withInvalid3DSURL_returnsError() { - mockAPIClient.cannedJSONResponse = CardResponses.confirmPaymentSourceJsonInvalid3DSURL.rawValue + mockCheckoutOrdersAPI.stubConfirmResponse = FakeConfirmPaymentResponse.withInvalid3DSURL let expectation = expectation(description: "approveOrder() completed") @@ -132,14 +129,14 @@ class CardClient_Tests: XCTestCase { XCTFail("Invoked willLaunch() callback. Should invoke error().") }) - cardClient.delegate = mockCardDelegate - cardClient.approveOrder(request: cardRequest) + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) waitForExpectations(timeout: 10) } func testApproveOrder_withNoThreeDSecure_returnsOrderData() { - mockAPIClient.cannedJSONResponse = CardResponses.confirmPaymentSourceJson.rawValue + mockCheckoutOrdersAPI.stubConfirmResponse = FakeConfirmPaymentResponse.without3DS let expectation = expectation(description: "approveOrder() completed") @@ -152,43 +149,65 @@ class CardClient_Tests: XCTestCase { XCTFail("Invoked willLaunch() callback. Should invoke success().") }) - cardClient.delegate = mockCardDelegate - cardClient.approveOrder(request: cardRequest) + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) waitForExpectations(timeout: 10) } - func testApproveOrder_whenApiCallFails_returnsError() { - mockAPIClient.cannedJSONResponse = """ - { - "some_unexpected_response": "something" - } - """ - + func testApproveOrder_whenConfirmPaymentSDKError_bubblesError() { + mockCheckoutOrdersAPI.stubError = CoreSDKError(code: 123, domain: "sdk-domain", errorDescription: "sdk-error-desc") + let expectation = expectation(description: "approveOrder() completed") let mockCardDelegate = MockCardDelegate(success: {_, _ -> Void in XCTFail("Invoked success() callback. Should invoke error().") }, error: { _, error in - XCTAssertEqual(error.domain, APIClientError.domain) - XCTAssertEqual(error.code, APIClientError.Code.jsonDecodingError.rawValue) + XCTAssertEqual(error.domain, "sdk-domain") + XCTAssertEqual(error.code, 123) + XCTAssertEqual(error.localizedDescription, "sdk-error-desc") + expectation.fulfill() + }, threeDSWillLaunch: { _ in + XCTFail("Invoked willLaunch() callback. Should invoke error().") + }) + + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) + + waitForExpectations(timeout: 10) + } + + func testApproveOrder_whenConfirmPaymentGeneralError_returnsUnknownError() { + mockCheckoutOrdersAPI.stubError = NSError( + domain: "ns-fake-domain", + code: 123, + userInfo: [NSLocalizedDescriptionKey: "ns-fake-error"] + ) + + let expectation = expectation(description: "approveOrder() completed") + + let mockCardDelegate = MockCardDelegate(success: {_, _ -> Void in + XCTFail("Invoked success() callback. Should invoke error().") + }, error: { _, error in + XCTAssertEqual(error.domain, CardClientError.domain) + XCTAssertEqual(error.code, CardClientError.Code.unknown.rawValue) XCTAssertNotNil(error.localizedDescription) expectation.fulfill() }, threeDSWillLaunch: { _ in XCTFail("Invoked willLaunch() callback. Should invoke error().") }) - cardClient.delegate = mockCardDelegate - cardClient.approveOrder(request: cardRequest) - + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) + waitForExpectations(timeout: 10) } func testApproveOrder_withThreeDSecure_browserSwitchLaunches_getOrderReturnsSuccess() { - mockAPIClient.cannedJSONResponse = CardResponses.successfullGetOrderJson.rawValue - + mockCheckoutOrdersAPI.stubConfirmResponse = FakeConfirmPaymentResponse.withValid3DSURL + let expectation = expectation(description: "approveOrder() completed") - + let mockCardDelegate = MockCardDelegate( success: {_, result in XCTAssertEqual(result.orderID, "testOrderId") @@ -201,21 +220,21 @@ class CardClient_Tests: XCTestCase { cancel: { _ in XCTFail("Invoked cancel() callback. Should invoke success().") }, threeDSWillLaunch: { _ -> Void in XCTAssert(true) }, threeDSLaunched: { _ -> Void in XCTAssert(true) }) - - cardClient.delegate = mockCardDelegate - cardClient.approveOrder(request: cardRequest) - + + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) + waitForExpectations(timeout: 10) } func testApproveOrder_withThreeDSecure_userCancelsBrowser() { - mockAPIClient.cannedJSONResponse = CardResponses.confirmPaymentSourceJsonWith3DS.rawValue + mockCheckoutOrdersAPI.stubConfirmResponse = FakeConfirmPaymentResponse.withValid3DSURL mockWebAuthSession.cannedErrorResponse = ASWebAuthenticationSessionError( .canceledLogin, userInfo: ["Description": "Mock cancellation error description."] ) - + let expectation = expectation(description: "approveOrder() completed") let mockCardDelegate = MockCardDelegate( @@ -234,15 +253,15 @@ class CardClient_Tests: XCTestCase { threeDSWillLaunch: { _ in XCTAssert(true) }, threeDSLaunched: { _ in XCTAssert(true) }) - cardClient.delegate = mockCardDelegate - cardClient.approveOrder(request: cardRequest) + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) waitForExpectations(timeout: 10) } func testApproveOrder_withThreeDSecure_browserReturnsError() { - mockAPIClient.cannedJSONResponse = CardResponses.confirmPaymentSourceJsonWith3DS.rawValue - + mockCheckoutOrdersAPI.stubConfirmResponse = FakeConfirmPaymentResponse.withValid3DSURL + mockWebAuthSession.cannedErrorResponse = CoreSDKError( code: CardClientError.Code.threeDSecureError.rawValue, domain: CardClientError.domain, @@ -269,8 +288,8 @@ class CardClient_Tests: XCTestCase { threeDSWillLaunch: { _ in XCTAssert(true) }, threeDSLaunched: { _ in XCTAssert(true) }) - cardClient.delegate = mockCardDelegate - cardClient.approveOrder(request: cardRequest) + sut.delegate = mockCardDelegate + sut.approveOrder(request: cardRequest) waitForExpectations(timeout: 10) } diff --git a/UnitTests/CardPaymentsTests/CardResponses.swift b/UnitTests/CardPaymentsTests/CardResponses.swift deleted file mode 100644 index 570894a08..000000000 --- a/UnitTests/CardPaymentsTests/CardResponses.swift +++ /dev/null @@ -1,83 +0,0 @@ -enum CardResponses: String { - case confirmPaymentSourceJson = """ - { - "id": "testOrderId", - "status": "APPROVED", - "payment_source": { - "card": { - "last_four_digits": "7321", - "brand": "VISA", - "type": "CREDIT" - } - } - } - """ - - case confirmPaymentSourceJsonWith3DS = """ - { - "id": "testOrderId", - "status": "APPROVED", - "payment_source": { - "card": { - "last_four_digits": "7321", - "brand": "VISA", - "type": "CREDIT" - } - }, - "links": [ - { - "rel": "payer-action", - "href": "https://fakeURL?PayerID=98765" - } - ] - } - """ - - case confirmPaymentSourceJsonInvalid3DSURL = """ - { - "id": "testOrderId", - "status": "APPROVED", - "payment_source": { - "card": { - "last_four_digits": "7321", - "brand": "VISA", - "type": "CREDIT" - } - }, - "links": [ - { - "rel": "payer-action", - "href": "" - } - ] - } - """ - - case successfullGetOrderJson = """ - { - "id": "testOrderId", - "status": "CREATED", - "intent": "CAPTURE", - "payment_source": { - "card": { - "last_four_digits": "7321", - "brand": "VISA", - "type": "CREDIT", - "authentication_result": { - "liability_shift": "POSSIBLE", - "three_d_secure": { - "enrollment_status": "Y", - "authentication_status": "Y" - } - } - } - }, - "links": [ - { - "rel": "payer-action", - "href": "https://fakeURL?PayerID=98765" - } - ] - } - """ -} diff --git a/UnitTests/CardPaymentsTests/CheckoutOrdersAPI_Tests.swift b/UnitTests/CardPaymentsTests/CheckoutOrdersAPI_Tests.swift new file mode 100644 index 000000000..73268b971 --- /dev/null +++ b/UnitTests/CardPaymentsTests/CheckoutOrdersAPI_Tests.swift @@ -0,0 +1,112 @@ +import Foundation +import XCTest +@testable import CardPayments +@testable import CorePayments +@testable import TestShared + +class CheckoutOrdersAPI_Tests: XCTestCase { + + // MARK: - Helper Properties + + var sut: CheckoutOrdersAPI! + var mockAPIClient: MockAPIClient! + let coreConfig = CoreConfig(clientID: "fake-client-id", environment: .sandbox) + let cardRequest = CardRequest( + orderID: "my-order-id", + card: Card( + number: "4111", + expirationMonth: "01", + expirationYear: "1234", + securityCode: "123" + ) + ) + + // MARK: - Test Lifecycle + + override func setUp() { + super.setUp() + + let mockHTTP = MockHTTP(coreConfig: coreConfig) + mockAPIClient = MockAPIClient(http: mockHTTP) + sut = CheckoutOrdersAPI(coreConfig: coreConfig, apiClient: mockAPIClient) + } + + // MARK: - confirmPaymentSource() + + func testConfirmPaymentSource_constructsRESTRequestForV2CheckoutOrders() async { + _ = try? await sut.confirmPaymentSource(cardRequest: cardRequest) + + XCTAssertEqual(mockAPIClient.capturedRESTRequest?.path, "/v2/checkout/orders/my-order-id/confirm-payment-source") + XCTAssertEqual(mockAPIClient.capturedRESTRequest?.method, .post) + XCTAssertEqual(mockAPIClient.capturedRESTRequest?.queryParameters, nil) + + let postBody = mockAPIClient.capturedRESTRequest?.postParameters as! ConfirmPaymentSourceRequest + XCTAssertEqual(postBody.returnURL, "sdk.ios.paypal://card/success") + XCTAssertEqual(postBody.cancelURL, "sdk.ios.paypal://card/cancel") + XCTAssertEqual(postBody.cardRequest.orderID, "my-order-id") + XCTAssertEqual(postBody.cardRequest.card.number, "4111") + XCTAssertEqual(postBody.cardRequest.card.expirationMonth, "01") + XCTAssertEqual(postBody.cardRequest.card.expirationYear, "1234") + XCTAssertEqual(postBody.cardRequest.card.securityCode, "123") + } + + func testConfirmPaymentSource_whenAPIClientError_bubblesError() async { + mockAPIClient.stubHTTPError = CoreSDKError(code: 123, domain: "api-client-error", errorDescription: "error-desc") + + do { + _ = try await sut.confirmPaymentSource(cardRequest: cardRequest) + XCTFail("Expected error throw.") + } catch { + let error = error as! CoreSDKError + XCTAssertEqual(error.domain, "api-client-error") + XCTAssertEqual(error.code, 123) + XCTAssertEqual(error.localizedDescription, "error-desc") + } + } + + func testConfirmPaymentSource_whenSuccess_returnsParsedConfirmPaymentSourceResponse() async throws { + let successsResponseJSON = """ + { + "id": "testOrderId", + "status": "CREATED", + "intent": "CAPTURE", + "payment_source": { + "card": { + "last_four_digits": "7321", + "brand": "VISA", + "type": "CREDIT", + "authentication_result": { + "liability_shift": "POSSIBLE", + "three_d_secure": { + "enrollment_status": "Y", + "authentication_status": "Y" + } + } + } + }, + "links": [ + { + "rel": "payer-action", + "href": "some-url" + } + ] + } + """ + + let data = successsResponseJSON.data(using: .utf8) + let stubbedHTTPResponse = HTTPResponse(status: 200, body: data) + mockAPIClient.stubHTTPResponse = stubbedHTTPResponse + + let response = try await sut.confirmPaymentSource(cardRequest: cardRequest) + XCTAssertEqual(response.id, "testOrderId") + XCTAssertEqual(response.status, "CREATED") + XCTAssertEqual(response.paymentSource?.card.lastFourDigits, "7321") + XCTAssertEqual(response.paymentSource?.card.brand, "VISA") + XCTAssertEqual(response.paymentSource?.card.type, "CREDIT") + XCTAssertEqual(response.paymentSource?.card.authenticationResult?.liabilityShift, "POSSIBLE") + XCTAssertEqual(response.paymentSource?.card.authenticationResult?.threeDSecure?.enrollmentStatus, "Y") + XCTAssertEqual(response.paymentSource?.card.authenticationResult?.threeDSecure?.authenticationStatus, "Y") + XCTAssertEqual(response.links?.first?.rel, "payer-action") + XCTAssertEqual(response.links?.first?.href, "some-url") + } +} diff --git a/UnitTests/CardPaymentsTests/ConfirmPaymentSourceRequest_Tests.swift b/UnitTests/CardPaymentsTests/ConfirmPaymentSourceRequest_Tests.swift index 9f1eed2a1..daf888468 100644 --- a/UnitTests/CardPaymentsTests/ConfirmPaymentSourceRequest_Tests.swift +++ b/UnitTests/CardPaymentsTests/ConfirmPaymentSourceRequest_Tests.swift @@ -5,98 +5,90 @@ import XCTest @testable import TestShared class ConfirmPaymentSourceRequest_Tests: XCTestCase { - - func testEncodingPaymentSource_withValidCard() throws { - let mockOrderID = "mockOrderID" - let card = Card( - number: "4032036247327321", - expirationMonth: "11", - expirationYear: "2024", - securityCode: "222" + + // TODO: - Move to SDK wrapper on JSONEncoder for use in tests & APIClient + let encoder = JSONEncoder() + + override func setUp() { + super.setUp() + encoder.keyEncodingStrategy = .convertToSnakeCase + } + + func testEncode_properlyFormatsJSON() throws { + let sut = ConfirmPaymentSourceRequest( + cardRequest: CardRequest( + orderID: "some-order-id", + card: Card( + number: "some-number", + expirationMonth: "some-month", + expirationYear: "some-year", + securityCode: "some-code", + cardholderName: "some-name", + billingAddress: Address( + addressLine1: "some-line-1", + addressLine2: "some-line-2", + locality: "some-locality", + region: "some-region", + postalCode: "some-postal-code", + countryCode: "some-country" + ) + ), + sca: .scaAlways + ) ) - let cardRequest = CardRequest(orderID: mockOrderID, card: card) - let confirmPaymentSourceRequest = try XCTUnwrap( - ConfirmPaymentSourceRequest(clientID: "fake-token", cardRequest: cardRequest) - ) + let data = try encoder.encode(sut) + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] + + XCTAssertEqual(json?["application_context"]?["return_url"] as! String, "sdk.ios.paypal://card/success") + XCTAssertEqual(json?["application_context"]?["cancel_url"] as! String, "sdk.ios.paypal://card/cancel") - let paymentSourceBody = try XCTUnwrap(confirmPaymentSourceRequest.body) + let cardLevel = json?["payment_source"]?["card"] as! [String: Any] + XCTAssertEqual(cardLevel["number"] as! String, "some-number") + XCTAssertEqual(cardLevel["expiry"] as! String, "some-year-some-month") + XCTAssertEqual(cardLevel["name"] as! String, "some-name") - let expectedPaymentSourceDict: [String: Any?] = [ - "application_context": [ - "return_url": "sdk.ios.paypal://card/success", - "cancel_url": "sdk.ios.paypal://card/cancel" - ], - "payment_source": [ - "card": [ - "number": "4032036247327321", - "security_code": "222", - "billing_address": nil, - "name": nil, - "attributes": [ - "verification": [ - "method": "SCA_WHEN_REQUIRED" - ] - ], - "expiry": "2024-11" - ] as [String: Any?] - ] - ] - let paymentSourceDict = try JSONSerialization.jsonObject(with: paymentSourceBody, options: []) as! [String: Any] - XCTAssertEqual(paymentSourceDict as NSDictionary, expectedPaymentSourceDict as NSDictionary) - } - - func testEncodingPaymentSource_withValidCardDictionary_expectsValidHTTPParams() throws { - let mockOrderId = "mockOrderId" - let card = Card( - number: "4032036247327321", - expirationMonth: "11", - expirationYear: "2024", - securityCode: "222" - ) - let cardRequest = CardRequest(orderID: mockOrderId, card: card) - - let confirmPaymentSourceRequest = try XCTUnwrap( - ConfirmPaymentSourceRequest(clientID: "fake-token", cardRequest: cardRequest) - ) - - let modifiedClientID = "fake-token" + ":" - let expectedBase64EncodedClientID = Data(modifiedClientID.utf8).base64EncodedString() - let expectedPath = "/v2/checkout/orders/\(mockOrderId)/confirm-payment-source" - let expectedMethod = HTTPMethod.post - let expectedHeaders: [HTTPHeader: String] = [ - .contentType: "application/json", .acceptLanguage: "en_US", - .authorization: "Basic \(expectedBase64EncodedClientID)" - ] + let billingAddressLevel = cardLevel["billing_address"] as! [String: Any] + XCTAssertEqual(billingAddressLevel["address_line_1"] as! String, "some-line-1") + XCTAssertEqual(billingAddressLevel["address_line_2"] as! String, "some-line-2") + XCTAssertEqual(billingAddressLevel["admin_area_1"] as! String, "some-region") + XCTAssertEqual(billingAddressLevel["admin_area_2"] as! String, "some-locality") + XCTAssertEqual(billingAddressLevel["postal_code"] as! String, "some-postal-code") + XCTAssertEqual(billingAddressLevel["country_code"] as! String, "some-country") - XCTAssertEqual(confirmPaymentSourceRequest.path, expectedPath) - XCTAssertEqual(confirmPaymentSourceRequest.method, expectedMethod) - XCTAssertEqual(confirmPaymentSourceRequest.headers, expectedHeaders) + let attributesLevel = cardLevel["attributes"] as! [String: [String: Any]] + XCTAssertEqual(attributesLevel["verification"]?["method"] as! String, "SCA_ALWAYS") } - - func testEncodingFailure_throws_EncodingError() throws { - let mockOrderID = "mockOrderID" - let card = Card( - number: "4032036247327321", - expirationMonth: "11", - expirationYear: "2024", - securityCode: "222" + + func testEncode_withoutBillingAddress_properlyFormatsJSON() throws { + let sut = ConfirmPaymentSourceRequest( + cardRequest: CardRequest( + orderID: "some-order-id", + card: Card( + number: "some-number", + expirationMonth: "some-month", + expirationYear: "some-year", + securityCode: "some-code", + cardholderName: "some-name" + ), + sca: .scaAlways + ) ) - let cardRequest = CardRequest(orderID: mockOrderID, card: card) - let failingEncoder = FailingJSONEncoder() + let data = try encoder.encode(sut) + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] + + XCTAssertEqual(json?["application_context"]?["return_url"] as! String, "sdk.ios.paypal://card/success") + XCTAssertEqual(json?["application_context"]?["cancel_url"] as! String, "sdk.ios.paypal://card/cancel") - XCTAssertThrowsError( - try ConfirmPaymentSourceRequest( - clientID: "fake-client-id", - cardRequest: cardRequest, - encoder: failingEncoder) - ) { error in - guard let coreSDKError = error as? CoreSDKError else { - XCTFail("Thrown error should be a CoreSDKError") - return - } - XCTAssertEqual(coreSDKError.code, CardClientError.encodingError.code) - } + let cardLevel = json?["payment_source"]?["card"] as! [String: Any] + XCTAssertEqual(cardLevel["number"] as! String, "some-number") + XCTAssertEqual(cardLevel["expiry"] as! String, "some-year-some-month") + XCTAssertEqual(cardLevel["name"] as! String, "some-name") + + XCTAssertNil(cardLevel["billing_address"]) + + let attributesLevel = cardLevel["attributes"] as! [String: [String: Any]] + XCTAssertEqual(attributesLevel["verification"]?["method"] as! String, "SCA_ALWAYS") } } diff --git a/UnitTests/CardPaymentsTests/Mocks/FakeConfirmPaymentResponse.swift b/UnitTests/CardPaymentsTests/Mocks/FakeConfirmPaymentResponse.swift new file mode 100644 index 000000000..9931d02a0 --- /dev/null +++ b/UnitTests/CardPaymentsTests/Mocks/FakeConfirmPaymentResponse.swift @@ -0,0 +1,58 @@ +@testable import CardPayments + +enum FakeConfirmPaymentResponse { + + static let withValid3DSURL = ConfirmPaymentSourceResponse( + id: "testOrderId", + status: "APPROVED", + paymentSource: PaymentSource( + card: PaymentSource.Card( + lastFourDigits: "7321", + brand: "VISA", + type: "CREDIT", + authenticationResult: nil + ) + ), + links: [ + Link( + href: "https://fakeURL?PayerID=98765", + rel: "payer-action", + method: nil + ) + ] + ) + + static let withInvalid3DSURL = ConfirmPaymentSourceResponse( + id: "testOrderId", + status: "APPROVED", + paymentSource: PaymentSource( + card: PaymentSource.Card( + lastFourDigits: "7321", + brand: "VISA", + type: "CREDIT", + authenticationResult: nil + ) + ), + links: [ + Link( + href: "", + rel: "payer-action", + method: nil + ) + ] + ) + + static let without3DS = ConfirmPaymentSourceResponse( + id: "testOrderId", + status: "APPROVED", + paymentSource: PaymentSource( + card: PaymentSource.Card( + lastFourDigits: "7321", + brand: "VISA", + type: "CREDIT", + authenticationResult: nil + ) + ), + links: nil + ) +} diff --git a/UnitTests/CardPaymentsTests/MockCardDelegate.swift b/UnitTests/CardPaymentsTests/Mocks/MockCardDelegate.swift similarity index 100% rename from UnitTests/CardPaymentsTests/MockCardDelegate.swift rename to UnitTests/CardPaymentsTests/Mocks/MockCardDelegate.swift diff --git a/UnitTests/CardPaymentsTests/Mocks/MockCardVaultDelegate.swift b/UnitTests/CardPaymentsTests/Mocks/MockCardVaultDelegate.swift new file mode 100644 index 000000000..73e229fa1 --- /dev/null +++ b/UnitTests/CardPaymentsTests/Mocks/MockCardVaultDelegate.swift @@ -0,0 +1,24 @@ +@testable import CorePayments +@testable import CardPayments + +class MockCardVaultDelegate: CardVaultDelegate { + + private var success: ((CardClient, CardVaultResult) -> Void)? + private var failure: ((CardClient, CoreSDKError) -> Void)? + + required init( + success: ((CardClient, CardVaultResult) -> Void)? = nil, + error: ((CardClient, CoreSDKError) -> Void)? = nil + ) { + self.success = success + self.failure = error + } + + func card(_ cardClient: CardClient, didFinishWithVaultResult vaultResult: CardPayments.CardVaultResult) { + success?(cardClient, vaultResult) + } + + func card(_ cardClient: CardClient, didFinishWithVaultError vaultError: CorePayments.CoreSDKError) { + failure?(cardClient, vaultError) + } +} diff --git a/UnitTests/CardPaymentsTests/Mocks/MockCheckoutOrdersAPI.swift b/UnitTests/CardPaymentsTests/Mocks/MockCheckoutOrdersAPI.swift new file mode 100644 index 000000000..d42f38abe --- /dev/null +++ b/UnitTests/CardPaymentsTests/Mocks/MockCheckoutOrdersAPI.swift @@ -0,0 +1,25 @@ +import Foundation +@testable import CardPayments +@testable import CorePayments + +class MockCheckoutOrdersAPI: CheckoutOrdersAPI { + + var stubConfirmResponse: ConfirmPaymentSourceResponse? + var stubError: Error? + + var capturedCardRequest: CardRequest? + + override func confirmPaymentSource(cardRequest: CardRequest) async throws -> ConfirmPaymentSourceResponse { + capturedCardRequest = cardRequest + + if let stubError { + throw stubError + } + + if let stubConfirmResponse { + return stubConfirmResponse + } + + throw CoreSDKError(code: 0, domain: "", errorDescription: "Stubbed responses not implemented for this mock.") + } +} diff --git a/UnitTests/CardPaymentsTests/Mocks/MockVaultPaymentTokensAPI.swift b/UnitTests/CardPaymentsTests/Mocks/MockVaultPaymentTokensAPI.swift new file mode 100644 index 000000000..a86792baf --- /dev/null +++ b/UnitTests/CardPaymentsTests/Mocks/MockVaultPaymentTokensAPI.swift @@ -0,0 +1,25 @@ +import Foundation +@testable import CardPayments +@testable import CorePayments + +class MockVaultPaymentTokensAPI: VaultPaymentTokensAPI { + + var stubSetupTokenResponse: UpdateSetupTokenResponse? + var stubError: Error? + + var capturedCardVaultRequest: CardVaultRequest? + + override func updateSetupToken(cardVaultRequest: CardVaultRequest) async throws -> UpdateSetupTokenResponse { + capturedCardVaultRequest = cardVaultRequest + + if let stubError { + throw stubError + } + + if let stubSetupTokenResponse { + return stubSetupTokenResponse + } + + throw CoreSDKError(code: 0, domain: "", errorDescription: "Stubbed responses not implemented for this mock.") + } +} diff --git a/UnitTests/CardPaymentsTests/VaultPaymentTokensAPI_Tests.swift b/UnitTests/CardPaymentsTests/VaultPaymentTokensAPI_Tests.swift new file mode 100644 index 000000000..bdada858f --- /dev/null +++ b/UnitTests/CardPaymentsTests/VaultPaymentTokensAPI_Tests.swift @@ -0,0 +1,121 @@ +import Foundation +import XCTest +@testable import CardPayments +@testable import CorePayments +@testable import TestShared + +class VaultPaymentTokensAPI_Tests: XCTestCase { + + // MARK: - Helper Properties + + var sut: VaultPaymentTokensAPI! + var mockAPIClient: MockAPIClient! + let coreConfig = CoreConfig(clientID: "fake-client-id", environment: .sandbox) + let cardVaultRequest = CardVaultRequest( + card: Card( + number: "fake-number", + expirationMonth: "fake-month", + expirationYear: "fake-year", + securityCode: "fake-code", + cardholderName: "fake-name" + ), + setupTokenID: "some-token" + ) + + // MARK: - Test Lifecycle + + override func setUp() { + super.setUp() + + let mockHTTP = MockHTTP(coreConfig: coreConfig) + mockAPIClient = MockAPIClient(http: mockHTTP) + sut = VaultPaymentTokensAPI(coreConfig: coreConfig, apiClient: mockAPIClient) + } + + // MARK: - updateSetupToken() + + func testUpdateSetupToken_constructsGraphQLRequest() async { + let expectedQueryString = """ + mutation UpdateVaultSetupToken( + $clientID: String!, + $vaultSetupToken: String!, + $paymentSource: PaymentSource + ) { + updateVaultSetupToken( + clientId: $clientID + vaultSetupToken: $vaultSetupToken + paymentSource: $paymentSource + ) { + id, + status, + links { + rel, + href + } + } + } + """ + + _ = try? await sut.updateSetupToken(cardVaultRequest: cardVaultRequest) + + XCTAssertEqual(mockAPIClient.capturedGraphQLRequest?.query, expectedQueryString) + XCTAssertEqual(mockAPIClient.capturedGraphQLRequest?.queryNameForURL, "UpdateVaultSetupToken") + + let variables = mockAPIClient.capturedGraphQLRequest?.variables as! UpdateVaultVariables + XCTAssertEqual(variables.clientID, "fake-client-id") + XCTAssertEqual(variables.vaultRequest.setupTokenID, "some-token") + XCTAssertEqual(variables.vaultRequest.card.number, "fake-number") + XCTAssertEqual(variables.vaultRequest.card.expiry, "fake-year-fake-month") + XCTAssertEqual(variables.vaultRequest.card.securityCode, "fake-code") + XCTAssertEqual(variables.vaultRequest.card.cardholderName, "fake-name") + } + + func testUpdateSetupToken_whenAPIClientError_bubblesError() async { + mockAPIClient.stubHTTPError = CoreSDKError(code: 123, domain: "api-client-error", errorDescription: "error-desc") + + do { + _ = try await sut.updateSetupToken(cardVaultRequest: cardVaultRequest) + XCTFail("Expected error throw.") + } catch { + let error = error as! CoreSDKError + XCTAssertEqual(error.domain, "api-client-error") + XCTAssertEqual(error.code, 123) + XCTAssertEqual(error.localizedDescription, "error-desc") + } + } + + func testUpdateSetupToken_whenSuccess_returnsParsedUpdateSetupTokenResponse() async throws { + let successsResponseJSON = """ + { + "data": { + "updateVaultSetupToken": { + "id": "some-id", + "status": "some-status", + "links": [ + { + "rel": "some-rel", + "href": "some-href" + }, + { + "rel": "some-rel-2", + "href": "some-href-2" + } + ] + } + } + } + """ + + let data = successsResponseJSON.data(using: .utf8) + let stubbedHTTPResponse = HTTPResponse(status: 200, body: data) + mockAPIClient.stubHTTPResponse = stubbedHTTPResponse + + let response = try await sut.updateSetupToken(cardVaultRequest: cardVaultRequest) + XCTAssertEqual(response.updateVaultSetupToken.id, "some-id") + XCTAssertEqual(response.updateVaultSetupToken.status, "some-status") + XCTAssertEqual(response.updateVaultSetupToken.links.first?.rel, "some-rel") + XCTAssertEqual(response.updateVaultSetupToken.links.first?.href, "some-href") + XCTAssertEqual(response.updateVaultSetupToken.links.last?.rel, "some-rel-2") + XCTAssertEqual(response.updateVaultSetupToken.links.last?.href, "some-href-2") + } +} diff --git a/UnitTests/PaymentsCoreTests/APIClient_Tests.swift b/UnitTests/PaymentsCoreTests/APIClient_Tests.swift index b0baeb4b4..e5d8af2af 100644 --- a/UnitTests/PaymentsCoreTests/APIClient_Tests.swift +++ b/UnitTests/PaymentsCoreTests/APIClient_Tests.swift @@ -3,39 +3,179 @@ import XCTest @testable import TestShared class APIClient_Tests: XCTestCase { - + // MARK: - Helper Properties - + var sut: APIClient! - let mockHTTP = MockHTTP() - + var mockHTTP: MockHTTP! + var coreConfig = CoreConfig(clientID: "fake-client-id", environment: .sandbox) + let base64EncodedFakeClientID = "ZmFrZS1jbGllbnQtaWQ6" // "fake-client-id" encoded + let stubHTTPResponse = HTTPResponse(status: 200, body: nil) + // MARK: - Test lifecycle - + override func setUp() { super.setUp() + mockHTTP = MockHTTP(coreConfig: coreConfig) + mockHTTP.stubHTTPResponse = stubHTTPResponse sut = APIClient(http: mockHTTP) } - // MARK: - fetch() + // MARK: - fetch() REST + + func testFetchREST_whenLive_usesProperPayPalURL() async throws { + let mockHTTP = MockHTTP(coreConfig: CoreConfig(clientID: "123", environment: .live)) + mockHTTP.stubHTTPResponse = HTTPResponse(status: 200, body: nil) + let sut = APIClient(http: mockHTTP) + + let fakeGETRequest = RESTRequest(path: "", method: .get) + + _ = try await sut.fetch(request: fakeGETRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://api.paypal.com/") + } + + func testFetchREST_whenSandbox_usesProperPayPalURL() async throws { + let fakeGETRequest = RESTRequest(path: "", method: .get) + + _ = try await sut.fetch(request: fakeGETRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://api.sandbox.paypal.com/") + } + + func testFetchREST_whenGET_constructsProperHTTPRequest() async throws { + let fakeGETRequest = RESTRequest(path: "v1/fake-endpoint", method: .get, queryParameters: ["key1": "value1"]) + + _ = try await sut.fetch(request: fakeGETRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://api.sandbox.paypal.com/v1/fake-endpoint?key1=value1") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.method, .get) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.authorization], "Basic \(base64EncodedFakeClientID)") + } + + func testFetchREST_whenPOST_constructsProperHTTPRequest() async throws { + let postBody = FakeRequest(fakeParam: "some-param") + + let fakePOSTRequest = RESTRequest( + path: "v1/fake-endpoint", + method: .post, + postParameters: postBody + ) + + _ = try await sut.fetch(request: fakePOSTRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://api.sandbox.paypal.com/v1/fake-endpoint") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.method, .post) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.authorization], "Basic \(base64EncodedFakeClientID)") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.contentType], "application/json") + + let postData = mockHTTP.capturedHTTPRequest?.body + let postJSON = try JSONSerialization.jsonObject(with: postData!, options: []) as! [String: Any] + XCTAssertEqual(postJSON["fake_param"] as! String, "some-param") + } + + func testFetchREST_returnsHTTPResponse() async throws { + let fakeRequest = RESTRequest(path: "", method: .get) + + let result = try await sut.fetch(request: fakeRequest) + XCTAssertEqual(result, stubHTTPResponse) + } + + func testFetchREST_whenSuccess_bubblesHTTPResponse() async throws { + let fakeRequest = RESTRequest(path: "", method: .get) + + let result = try await sut.fetch(request: fakeRequest) + XCTAssertEqual(result, stubHTTPResponse) + } + + func testFetchREST_whenError_bubblesHTTPErrorThrow() async throws { + mockHTTP.stubHTTPError = CoreSDKError(code: 0, domain: "", errorDescription: "Fake error from HTTP") + + let fakeRequest = RESTRequest(path: "", method: .get) + + do { + _ = try await sut.fetch(request: fakeRequest) + XCTFail("Expected an error to be thrown.") + } catch { + let error = error as NSError + XCTAssertEqual(error.localizedDescription, "Fake error from HTTP") + } + } + + // MARK: - fetch() GraphQL + + func testFetchGraphQL_whenLive_usesProperPayPalURL() async throws { + let mockHTTP = MockHTTP(coreConfig: CoreConfig(clientID: "123", environment: .live)) + mockHTTP.stubHTTPResponse = HTTPResponse(status: 200, body: nil) + let sut = APIClient(http: mockHTTP) + + let fakeGraphQLRequest = GraphQLRequest(query: "fake-query", variables: FakeRequest(fakeParam: "fake-param")) + + _ = try await sut.fetch(request: fakeGraphQLRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://paypal.com/graphql") + } + + func testFetchGraphQL_whenSandbox_usesProperPayPalURL() async throws { + let fakeGraphQLRequest = GraphQLRequest(query: "fake-query", variables: FakeRequest(fakeParam: "fake-param")) + + _ = try await sut.fetch(request: fakeGraphQLRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://www.sandbox.paypal.com/graphql") + } + + func testFetchGraphQL_constructsProperHTTPRequest() async throws { + let fakeGraphQLRequest = GraphQLRequest( + query: #"{ sample { query } }"#, + variables: FakeRequest(fakeParam: "my-param") + ) + + _ = try await sut.fetch(request: fakeGraphQLRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://www.sandbox.paypal.com/graphql") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.method, .post) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.accept], "application/json") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.contentType], "application/json") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.appName], "ppcpmobilesdk") + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.headers[.origin], coreConfig.environment.graphQLURL.absoluteString) + + let postData = mockHTTP.capturedHTTPRequest?.body + let postJSON = try JSONSerialization.jsonObject(with: postData!, options: []) as! [String: Any] + XCTAssertEqual(postJSON["query"] as! String, #"{ sample { query } }"#) + XCTAssertNotNil(postJSON["variables"]) + XCTAssertEqual((postJSON["variables"] as! [String: String])["fakeParam"], "my-param") + } + + func testFetchGraphQL_whenQueryNameSpecified_appendsToURL() async throws { + let fakeGraphQLRequest = GraphQLRequest( + query: "", + variables: FakeRequest(fakeParam: "my-param"), + queryNameForURL: "FakeName" + ) + + _ = try await sut.fetch(request: fakeGraphQLRequest) + XCTAssertEqual(mockHTTP.capturedHTTPRequest?.url.absoluteString, "https://www.sandbox.paypal.com/graphql?FakeName") + } - func testFetch_forwardsAPIRequestToHTTPClass() async throws { - _ = try? await sut.fetch(request: FakeRequest()) + func testFetchGraphQL_whenSuccess_bubblesHTTPResponse() async throws { + let fakeGraphQLRequest = GraphQLRequest(query: "fake-query", variables: FakeRequest(fakeParam: "fake-param")) - XCTAssert(mockHTTP.lastAPIRequest is FakeRequest) - XCTAssertEqual(mockHTTP.lastAPIRequest?.path, "/fake-path") + let result = try await sut.fetch(request: fakeGraphQLRequest) + XCTAssertEqual(result, stubHTTPResponse) } - func testFetch_parsesHTTPResponse() async { - let jsonResponse = #"{ "fake_param": "fake-response" }"# - mockHTTP.stubHTTPResponse = HTTPResponse(status: 200, body: jsonResponse.data(using: .utf8)!) + func testFetchGraphQL_whenError_bubblesHTTPErrorThrow() async throws { + mockHTTP.stubHTTPError = CoreSDKError(code: 0, domain: "", errorDescription: "Fake error from HTTP") + + let fakeGraphQLRequest = GraphQLRequest(query: "fake-query", variables: FakeRequest(fakeParam: "fake-param")) do { - let response = try await sut.fetch(request: FakeRequest()) - XCTAssert((response as Any) is FakeResponse) - XCTAssertEqual(response.fakeParam, "fake-response") + _ = try await sut.fetch(request: fakeGraphQLRequest) + XCTFail("Expected an error to be thrown.") } catch { - XCTFail("Expected fetch() to succeed") + let error = error as NSError + XCTAssertEqual(error.localizedDescription, "Fake error from HTTP") } } } + +extension HTTPResponse: Equatable { + + public static func == (lhs: HTTPResponse, rhs: HTTPResponse) -> Bool { + return lhs.body == rhs.body && lhs.isSuccessful == rhs.isSuccessful && lhs.status == rhs.status + } +} diff --git a/UnitTests/PaymentsCoreTests/AnalyticsEventRequest_Tests.swift b/UnitTests/PaymentsCoreTests/AnalyticsEventData_Tests.swift similarity index 70% rename from UnitTests/PaymentsCoreTests/AnalyticsEventRequest_Tests.swift rename to UnitTests/PaymentsCoreTests/AnalyticsEventData_Tests.swift index 1b4d5ffd9..7b69dd98f 100644 --- a/UnitTests/PaymentsCoreTests/AnalyticsEventRequest_Tests.swift +++ b/UnitTests/PaymentsCoreTests/AnalyticsEventData_Tests.swift @@ -1,31 +1,28 @@ import XCTest @testable import CorePayments -class AnalyticsEventRequest_Tests: XCTestCase { +class AnalyticsEventData_Tests: XCTestCase { - var fakeAnalyticsEventData: AnalyticsEventData! - var sut: AnalyticsEventRequest! + var sut: AnalyticsEventData! let currentTime = String(Date().timeIntervalSince1970 * 1000) let oneSecondLater = String((Date().timeIntervalSince1970 * 1000) + 999) override func setUp() { super.setUp() - fakeAnalyticsEventData = AnalyticsEventData( + sut = AnalyticsEventData( environment: "fake-env", eventName: "fake-name", clientID: "fake-client-id", orderID: "fake-order" ) - - sut = try! AnalyticsEventRequest(eventData: fakeAnalyticsEventData) } - func test_httpParameters() throws { - let bodyData = sut.body - let jsonBody = try? JSONSerialization.jsonObject(with: bodyData!) as? [String: [String: [String: Any]]] + func testEncode_properlyFormatsJSON() throws { + let data = try JSONEncoder().encode(sut) + let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: [String: Any]]] - guard let eventParams = jsonBody?["events"]?["event_params"] else { + guard let eventParams = json?["events"]?["event_params"] else { XCTFail("JSON body missing `event_params` key.") return } @@ -49,15 +46,6 @@ class AnalyticsEventRequest_Tests: XCTestCase { XCTAssertGreaterThanOrEqual(eventParams["t"] as! String, currentTime) XCTAssertLessThanOrEqual(eventParams["t"] as! String, oneSecondLater) XCTAssertEqual(eventParams["tenant_name"] as? String, "PayPal") - - XCTAssertEqual(sut.path, "v1/tracking/events") - XCTAssertEqual(sut.method, HTTPMethod.post) - XCTAssertEqual(sut.headers, [.contentType: "application/json"]) - } - - func testToURLRequest_overridesSandboxBaseURL() throws { - let urlRequest = sut.toURLRequest(environment: .sandbox) - XCTAssertEqual(urlRequest?.url?.absoluteString, "https://api.paypal.com/v1/tracking/events") } } diff --git a/UnitTests/PaymentsCoreTests/AnalyticsService_Tests.swift b/UnitTests/PaymentsCoreTests/AnalyticsService_Tests.swift index 4ad10ebba..f2d49994f 100644 --- a/UnitTests/PaymentsCoreTests/AnalyticsService_Tests.swift +++ b/UnitTests/PaymentsCoreTests/AnalyticsService_Tests.swift @@ -7,85 +7,47 @@ class AnalyticsService_Tests: XCTestCase { // MARK: - Helper properties var sut: AnalyticsService! - var mockHTTP: MockHTTP! - var coreConfig = CoreConfig(clientID: "fake-client-id", environment: .sandbox) - let clientIDResponseJSON = #"{ "client_id": "fake-client-id" }"# + var mockTrackingEventsAPI: MockTrackingEventsAPI! + var coreConfig = CoreConfig(clientID: "some-client-id", environment: .sandbox) // MARK: - Test lifecycle override func setUp() { super.setUp() - mockHTTP = MockHTTP() - mockHTTP.stubHTTPResponse = HTTPResponse(status: 200, body: clientIDResponseJSON.data(using: .utf8)!) - - sut = AnalyticsService(coreConfig: coreConfig, orderID: "fake-order-id", http: mockHTTP) + mockTrackingEventsAPI = MockTrackingEventsAPI(coreConfig: coreConfig) + sut = AnalyticsService(coreConfig: coreConfig, orderID: "some-order-id", trackingEventsAPI: mockTrackingEventsAPI) } // MARK: - sendEvent() - - func testSendEvent_whenClientID_postsAnalyticsEventRequestType() async { - await sut.performEventRequest("fake-event") - - XCTAssert(mockHTTP.lastAPIRequest is AnalyticsEventRequest) - } - - func testSendEvent_whenLive_sendsProperTag() async { - let coreConfig = CoreConfig(clientID: "fake-token", environment: .live) - let mockHTTP = MockHTTP(coreConfig: coreConfig) - mockHTTP.stubHTTPResponse = HTTPResponse(status: 200, body: clientIDResponseJSON.data(using: .utf8)!) - - let sut = AnalyticsService(coreConfig: coreConfig, orderID: "fake-orderID", http: mockHTTP) - await sut.performEventRequest("fake-event") + func testSendEvent_sendsAppropriateAnalyticsEventData() async { + await sut.performEventRequest("some-event") - guard let env = parsePostParam(from: mockHTTP.lastPOSTParameters, forKey: "merchant_sdk_env") else { - XCTFail("JSON body missing `merchant_sdk_env` key.") - return - } - - XCTAssertEqual(env, "live") + XCTAssertEqual(mockTrackingEventsAPI.capturedAnalyticsEventData?.eventName, "some-event") + XCTAssertEqual(mockTrackingEventsAPI.capturedAnalyticsEventData?.clientID, "some-client-id") + XCTAssertEqual(mockTrackingEventsAPI.capturedAnalyticsEventData?.orderID, "some-order-id") } - func testSendEvent_whenSandbox_sendsProperTag() async { - await sut.performEventRequest("fake-event") + func testSendEvent_whenLive_sendsAppropriateEnvName() async { + let sut = AnalyticsService( + coreConfig: CoreConfig(clientID: "some-client-id", environment: .live), + orderID: "some-order-id", + trackingEventsAPI: mockTrackingEventsAPI + ) - guard let env = parsePostParam(from: mockHTTP.lastPOSTParameters, forKey: "merchant_sdk_env") else { - XCTFail("JSON body missing `merchant_sdk_env` key.") - return - } + await sut.performEventRequest("some-event") - XCTAssertEqual(env, "sandbox") + XCTAssertEqual(mockTrackingEventsAPI.capturedAnalyticsEventData?.environment, "live") } - func testSendEvent_addsMetadataParams() async { - await sut.performEventRequest("fake-event") - - guard let eventName = parsePostParam(from: mockHTTP.lastPOSTParameters, forKey: "event_name") else { - XCTFail("JSON body missing `event_name` key.") - return - } - - guard let clientID = parsePostParam(from: mockHTTP.lastPOSTParameters, forKey: "partner_client_id") else { - XCTFail("JSON body missing `partner_client_id` key.") - return - } + func testSendEvent_whenSandbox_sendsAppropriateEnvName() async { + await sut.performEventRequest("some-event") - guard let orderID = parsePostParam(from: mockHTTP.lastPOSTParameters, forKey: "order_id") else { - XCTFail("JSON body missing `order_id` key.") - return - } - - XCTAssertEqual(eventName, "fake-event") - XCTAssertEqual(clientID, "fake-client-id") - XCTAssertEqual(orderID, "fake-order-id") + XCTAssertEqual(mockTrackingEventsAPI.capturedAnalyticsEventData?.environment, "sandbox") } - // MARK: - Helpers - - private func parsePostParam(from postParameters: [String: Any]?, forKey key: String) -> String? { - let topLevelEvent = postParameters?["events"] as? [String: Any] - let eventParams = topLevelEvent?["event_params"] as? [String: Any] - return eventParams?[key] as? String + func testSendEvent_whenAPIRequestFails_logsErrorToConsole() { + // We currently have no way to validate our console logging } } diff --git a/UnitTests/PaymentsCoreTests/GraphQLClient_Tests.swift b/UnitTests/PaymentsCoreTests/GraphQLClient_Tests.swift deleted file mode 100644 index 166476371..000000000 --- a/UnitTests/PaymentsCoreTests/GraphQLClient_Tests.swift +++ /dev/null @@ -1,71 +0,0 @@ -import XCTest -@testable import CorePayments -@testable import TestShared - -class GraphQLClient_Tests: XCTestCase { - - let mockClientID = "mockClientId" - - // MARK: - Helper Properties - - let successURLResponse = HTTPURLResponse(url: URL(string: "www.test.com")!, statusCode: 200, httpVersion: "https", headerFields: [:]) - let fakeRequest = FakeRequest() - var config: CoreConfig! - var mockURLSession: MockURLSession! - var graphQLClient: GraphQLClient! - var graphQLQuery: (any GraphQLQuery)? - - // TODO: add unit tests in appropriate class after networking & graphQL layer refactor is done - - // MARK: - Test lifecycle - - override func setUp() { - super.setUp() - config = CoreConfig(clientID: mockClientID, environment: .sandbox) - mockURLSession = MockURLSession() - mockURLSession.cannedError = nil - mockURLSession.cannedURLResponse = nil - mockURLSession.cannedJSONData = nil - - graphQLClient = GraphQLClient(environment: .sandbox, urlSession: mockURLSession) - } - - // MARK: - fetch() tests - func testGraphQLClient_verifyEmptyResponse() async throws { - mockURLSession.cannedURLResponse = successURLResponse - mockURLSession.cannedJSONData = graphQLQueryResponseWithoutData - - - mockURLSession.cannedURLResponse = HTTPURLResponse( - url: URL(string: "www.fake.com")!, - statusCode: 200, - httpVersion: "1", - headerFields: ["Paypal-Debug-Id": "454532"] - ) - } - - func testGraphQLClient_verifyNonEmptyResponse() async throws { - mockURLSession.cannedURLResponse = successURLResponse - mockURLSession.cannedJSONData = graphQLQueryResponseWithData - - - mockURLSession.cannedURLResponse = HTTPURLResponse( - url: URL(string: "www.fake.com")!, - statusCode: 200, - httpVersion: "1", - headerFields: ["Paypal-Debug-Id": "454532"] - ) - } - - let graphQLQueryResponseWithData = """ - { - "data": {} - } - """ - - let graphQLQueryResponseWithoutData = """ - { - - } - """ -} diff --git a/UnitTests/PaymentsCoreTests/HTTPResponseParser_Tests.swift b/UnitTests/PaymentsCoreTests/HTTPResponseParser_Tests.swift index d836ec664..0887726f6 100644 --- a/UnitTests/PaymentsCoreTests/HTTPResponseParser_Tests.swift +++ b/UnitTests/PaymentsCoreTests/HTTPResponseParser_Tests.swift @@ -8,49 +8,48 @@ class HTTPResponseParser_Tests: XCTestCase { let sut = HTTPResponseParser() - // MARK: - parse() + // MARK: - parseREST() - func testParse_whenNoBody_returnsError() { + func testParseREST_whenNoBody_returnsError() { let mockHTTPResponse = HTTPResponse(status: 200, body: nil) do { - _ = try sut.parse(mockHTTPResponse, as: FakeResponse.self) + _ = try sut.parseREST(mockHTTPResponse, as: FakeResponse.self) XCTFail("Expected parse() to throw") - } catch let error as CoreSDKError { + } catch { + let error = error as! CoreSDKError XCTAssertEqual(error.localizedDescription, "An error occured due to missing HTTP response data. Contact developer.paypal.com/support.") XCTAssertEqual(error.domain, APIClientError.domain) - } catch { - XCTFail("Unexpected error type") } } - func testParse_whenSuccessStatusCodeWithValidBody_returnsDecodable() { + func testParseREST_whenSuccessStatusCodeWithValidBody_returnsDecodable() { let jsonResponse = #"{ "fake_param": "fake-response" }"# let mockHTTPResponse = HTTPResponse(status: 203, body: jsonResponse.data(using: .utf8)!) do { - let response = try sut.parse(mockHTTPResponse, as: FakeResponse.self) + let response = try sut.parseREST(mockHTTPResponse, as: FakeResponse.self) XCTAssertEqual(response.fakeParam, "fake-response") } catch { XCTFail("Expected parse() to succeed") } } - func testParse_whenSuccessStatusCodeWithDecodingError_bubblesDecodingErrorMessage() { + func testParseREST_whenSuccessStatusCodeWithDecodingError_bubblesDecodingErrorMessage() { let jsonResponse = #"{ "incorrect_body": "borked-response" }"# let mockHTTPResponse = HTTPResponse(status: 203, body: jsonResponse.data(using: .utf8)!) let sut = HTTPResponseParser(decoder: FailingJSONDecoder()) do { - _ = try sut.parse(mockHTTPResponse, as: FakeResponse.self) + _ = try sut.parseREST(mockHTTPResponse, as: FakeResponse.self) XCTFail("Expected parse() to throw") } catch { XCTAssertEqual(error.localizedDescription, "Stub message from JSONDecoder.") } } - func testParse_whenBadStatusCodeWithErrorBody_returnsReadableError() { + func testParseREST_whenBadStatusCodeWithErrorBody_returnsReadableError() { let jsonResponse = """ { "name": "fake-error-name", @@ -60,13 +59,86 @@ class HTTPResponseParser_Tests: XCTestCase { let mockHTTPResponse = HTTPResponse(status: 500, body: jsonResponse.data(using: .utf8)!) do { - _ = try sut.parse(mockHTTPResponse, as: FakeResponse.self) + _ = try sut.parseREST(mockHTTPResponse, as: FakeResponse.self) XCTFail("Expected parse() to throw") - } catch let error as CoreSDKError { + } catch { + let error = error as! CoreSDKError XCTAssertEqual(error.localizedDescription, "fake-error-name: fake-message") XCTAssertEqual(error.domain, APIClientError.domain) + } + } + + // MARK: - parseGraphQL() + + func testParseGraphQL_whenNoBody_returnsError() { + let mockHTTPResponse = HTTPResponse(status: 200, body: nil) + + do { + _ = try sut.parseGraphQL(mockHTTPResponse, as: FakeResponse.self) + XCTFail("Expected parse() to throw") } catch { - XCTFail("Unexpected error type") + let error = error as! CoreSDKError + XCTAssertEqual(error.localizedDescription, "An error occured due to missing HTTP response data. Contact developer.paypal.com/support.") + XCTAssertEqual(error.domain, APIClientError.domain) + } + } + + func testParseGraphQL_whenSuccessStatusCodeWithValidBody_returnsDecodable() { + let jsonResponse = #"{ "data": { "fake_param": "fake-response" } }"# + let mockHTTPResponse = HTTPResponse(status: 203, body: jsonResponse.data(using: .utf8)!) + + do { + let response = try sut.parseGraphQL(mockHTTPResponse, as: FakeResponse.self) + XCTAssertEqual(response.fakeParam, "fake-response") + } catch { + XCTFail("Expected parse() to succeed") + } + } + + func testParseGraphQL_whenSuccessStatusCodeWithMissingDataField_returnsError() { + let jsonResponse = #"{ "fake_param": "fake-response" }"# + let mockHTTPResponse = HTTPResponse(status: 203, body: jsonResponse.data(using: .utf8)!) + + do { + _ = try sut.parseGraphQL(mockHTTPResponse, as: FakeResponse.self) + XCTFail("Expected parse() to throw") + } catch { + let error = error as! CoreSDKError + XCTAssertEqual(error.localizedDescription, "An error occured due to missing `data` key in GraphQL query response. Contact developer.paypal.com/support.") + XCTAssertEqual(error.domain, APIClientError.domain) + } + } + + func testParseGraphQL_whenSuccessStatusCodeWithDecodingError_bubblesDecodingErrorMessage() { + let jsonResponse = #"{ "incorrect_body": "borked-response" }"# + let mockHTTPResponse = HTTPResponse(status: 203, body: jsonResponse.data(using: .utf8)!) + + let sut = HTTPResponseParser(decoder: FailingJSONDecoder()) + + do { + _ = try sut.parseGraphQL(mockHTTPResponse, as: FakeResponse.self) + XCTFail("Expected parse() to throw") + } catch { + XCTAssertEqual(error.localizedDescription, "Stub message from JSONDecoder.") + } + } + + func testParseGraphQL_whenBadStatusCodeWithErrorBody_returnsReadableError() { + let jsonResponse = """ + { + "error": "fake-error-description", + "correlation_id": "fake-correlation-id" + } + """ + let mockHTTPResponse = HTTPResponse(status: 500, body: jsonResponse.data(using: .utf8)!) + + do { + _ = try sut.parseGraphQL(mockHTTPResponse, as: FakeResponse.self) + XCTFail("Expected parse() to throw") + } catch { + let error = error as! CoreSDKError + XCTAssertEqual(error.localizedDescription, "fake-error-description") + XCTAssertEqual(error.domain, APIClientError.domain) } } } diff --git a/UnitTests/PaymentsCoreTests/HTTP_Tests.swift b/UnitTests/PaymentsCoreTests/HTTP_Tests.swift index 11ef17664..d2e759475 100644 --- a/UnitTests/PaymentsCoreTests/HTTP_Tests.swift +++ b/UnitTests/PaymentsCoreTests/HTTP_Tests.swift @@ -6,7 +6,8 @@ class HTTP_Tests: XCTestCase { // MARK: - Helper Properties - var fakeRequest = FakeRequest() + let fakePostData = #"{ "fake": "data" }"#.data(using: .utf8) + var fakeHTTPRequest: HTTPRequest! let config = CoreConfig(clientID: "mockClientID", environment: .sandbox) var mockURLSession: MockURLSession! @@ -17,6 +18,8 @@ class HTTP_Tests: XCTestCase { override func setUp() { super.setUp() + fakeHTTPRequest = HTTPRequest(headers: [.appName: "fake-app"], method: .get, url: URL(string: "www.fake.com")!, body: fakePostData) + mockURLSession = MockURLSession() mockURLSession.cannedError = nil mockURLSession.cannedURLResponse = nil @@ -27,22 +30,18 @@ class HTTP_Tests: XCTestCase { coreConfig: config ) } - + // MARK: - performRequest() - func testPerformRequest_withNoURLRequest_returnsInvalidURLRequestError() async { - // Mock request whose API object does not vend a URLRequest - let noURLRequest = FakeRequestNoURL() - + func testPerformRequest_configuresURLRequest() async { do { - _ = try await sut.performRequest(noURLRequest) - XCTFail("This should have failed.") - } catch let error as CoreSDKError { - XCTAssertEqual(error.domain, APIClientError.domain) - XCTAssertEqual(error.code, APIClientError.Code.invalidURLRequest.rawValue) - XCTAssertEqual(error.localizedDescription, "An error occured constructing an HTTP request. Contact developer.paypal.com/support.") + _ = try await sut.performRequest(fakeHTTPRequest) + XCTAssertEqual(mockURLSession.capturedURLRequest?.url?.absoluteString, "www.fake.com") + XCTAssertEqual(mockURLSession.capturedURLRequest?.httpMethod, "GET") + XCTAssertEqual(mockURLSession.capturedURLRequest?.allHTTPHeaderFields, ["x-app-name": "fake-app"]) + XCTAssertEqual(mockURLSession.capturedURLRequest?.httpBody, fakePostData) } catch { - XCTFail("Unexpected error type") + XCTFail("Unexpected failure.") } } @@ -56,7 +55,7 @@ class HTTP_Tests: XCTestCase { mockURLSession.cannedError = serverError do { - _ = try await sut.performRequest(fakeRequest) + _ = try await sut.performRequest(fakeHTTPRequest) XCTFail("Request succeeded. Expected error.") } catch let error { XCTAssertTrue(serverError === (error as AnyObject)) @@ -67,7 +66,7 @@ class HTTP_Tests: XCTestCase { mockURLSession.cannedURLResponse = URLResponse() do { - _ = try await sut.performRequest(fakeRequest) + _ = try await sut.performRequest(fakeHTTPRequest) XCTFail("Request succeeded. Expected error.") } catch let error as CoreSDKError { XCTAssertEqual(error.domain, APIClientError.domain) @@ -77,4 +76,19 @@ class HTTP_Tests: XCTestCase { XCTFail("Unexpected error type") } } + + func testPerformRequest_formatsAndReturnsHTTPResponse() async { + let fakeJSONResponseString = #"{ "fake": "response-data" }"# + mockURLSession.cannedURLResponse = HTTPURLResponse(url: URL(string: "www.fake.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + mockURLSession.cannedJSONData = fakeJSONResponseString + + do { + let httpResponse = try await sut.performRequest(fakeHTTPRequest) + XCTAssertEqual(httpResponse.status, 200) + XCTAssertTrue(httpResponse.isSuccessful) + XCTAssertEqual(httpResponse.body, fakeJSONResponseString.data(using: .utf8)) + } catch { + XCTFail("Unexpected failure.") + } + } } diff --git a/UnitTests/PaymentsCoreTests/Mocks/MockTrackingEventsAPI.swift b/UnitTests/PaymentsCoreTests/Mocks/MockTrackingEventsAPI.swift new file mode 100644 index 000000000..a8d0eff19 --- /dev/null +++ b/UnitTests/PaymentsCoreTests/Mocks/MockTrackingEventsAPI.swift @@ -0,0 +1,24 @@ +import Foundation +@testable import CorePayments + +class MockTrackingEventsAPI: TrackingEventsAPI { + + var stubHTTPResponse: HTTPResponse? + var stubError: Error? + + var capturedAnalyticsEventData: AnalyticsEventData? + + override func sendEvent(with analyticsEventData: AnalyticsEventData) async throws -> HTTPResponse { + capturedAnalyticsEventData = analyticsEventData + + if let stubError { + throw stubError + } + + if let stubHTTPResponse { + return stubHTTPResponse + } + + throw CoreSDKError(code: 0, domain: "", errorDescription: "Stubbed responses not implemented for this mock.") + } +} diff --git a/UnitTests/PaymentsCoreTests/TrackingEventsAPI_Tests.swift b/UnitTests/PaymentsCoreTests/TrackingEventsAPI_Tests.swift new file mode 100644 index 000000000..2c1951b34 --- /dev/null +++ b/UnitTests/PaymentsCoreTests/TrackingEventsAPI_Tests.swift @@ -0,0 +1,76 @@ +import Foundation +import XCTest +@testable import TestShared +@testable import CorePayments + +class TrackingEventsAPI_Tests: XCTestCase { + + // MARK: - Helper Properties + + var sut: TrackingEventsAPI! + var mockAPIClient: MockAPIClient! + let coreConfig = CoreConfig(clientID: "fake-client-id", environment: .sandbox) + let stubHTTPResponse = HTTPResponse(status: 200, body: nil) + let fakeAnalyticsEventData = AnalyticsEventData( + environment: "my-env", + eventName: "my-event-name", + clientID: "my-id", + orderID: "my-order" + ) + + // MARK: - Test Lifecycle + + override func setUp() { + super.setUp() + + let mockHTTP = MockHTTP(coreConfig: coreConfig) + mockAPIClient = MockAPIClient(http: mockHTTP) + mockAPIClient.stubHTTPResponse = stubHTTPResponse + sut = TrackingEventsAPI(coreConfig: coreConfig, apiClient: mockAPIClient) + } + + // MARK: - sendEvent() REST + + func testSendEvent_alwaysUsesLiveConfig() { + let sut = TrackingEventsAPI(coreConfig: coreConfig) + XCTAssertEqual(sut.coreConfig.environment, .live) + } + + func testSendEvent_constructsRESTRequestForV1Tracking() async throws { + let fakeAnalyticsEventData = AnalyticsEventData( + environment: "my-env", + eventName: "my-event-name", + clientID: "my-id", + orderID: "my-order" + ) + _ = try await sut.sendEvent(with: fakeAnalyticsEventData) + + XCTAssertEqual(mockAPIClient.capturedRESTRequest?.path, "v1/tracking/events") + XCTAssertEqual(mockAPIClient.capturedRESTRequest?.method, .post) + XCTAssertNil(mockAPIClient.capturedRESTRequest?.queryParameters) + + let postData = mockAPIClient.capturedRESTRequest?.postParameters as! AnalyticsEventData + XCTAssertEqual(postData.environment, "my-env") + XCTAssertEqual(postData.eventName, "my-event-name") + XCTAssertEqual(postData.clientID, "my-id") + XCTAssertEqual(postData.orderID, "my-order") + } + + func testSendEvent_whenSuccess_bubblesHTTPResponse() async throws { + let httpResponse = try await sut.sendEvent(with: fakeAnalyticsEventData) + + XCTAssertEqual(httpResponse, stubHTTPResponse) + } + + func testSendEvent_whenError_bubblesAPIClientErrorThrow() async throws { + mockAPIClient.stubHTTPError = CoreSDKError(code: 0, domain: "", errorDescription: "Fake error from APIClient") + + do { + _ = try await sut.sendEvent(with: fakeAnalyticsEventData) + XCTFail("Expected an error to be thrown.") + } catch { + let error = error as NSError + XCTAssertEqual(error.localizedDescription, "Fake error from APIClient") + } + } +} diff --git a/UnitTests/TestShared/FakeRequests.swift b/UnitTests/TestShared/FakeRequests.swift index fb810602d..f382fe064 100644 --- a/UnitTests/TestShared/FakeRequests.swift +++ b/UnitTests/TestShared/FakeRequests.swift @@ -1,27 +1,12 @@ import Foundation import CorePayments -public struct FakeResponse: Codable { +public struct FakeResponse: Decodable { var fakeParam: String } -class FakeRequest: APIRequest { - - typealias ResponseType = FakeResponse - - public var path = "/fake-path" - public var method = HTTPMethod.post - public var headers = [HTTPHeader.accept: "test-header"] - - func toURLRequest(environment: Environment) -> URLRequest? { - composeURLRequest(environment: environment) - } -} - -class FakeRequestNoURL: FakeRequest { - - override func toURLRequest(environment: Environment) -> URLRequest? { - return nil - } +public struct FakeRequest: Encodable { + + var fakeParam: String } diff --git a/UnitTests/TestShared/MockAPIClient.swift b/UnitTests/TestShared/MockAPIClient.swift index fb4a9a2d0..48eaa0d63 100644 --- a/UnitTests/TestShared/MockAPIClient.swift +++ b/UnitTests/TestShared/MockAPIClient.swift @@ -3,21 +3,37 @@ import Foundation class MockAPIClient: APIClient { - var cannedClientIDError: CoreSDKError? - var cannedClientID = "cannedClientID" - var cannedJSONResponse: String? - var cannedFetchError: Error? + var stubHTTPResponse: HTTPResponse? + var stubHTTPError: Error? - var postedAnalyticsEvents: [String] = [] + var capturedRESTRequest: RESTRequest? + var capturedGraphQLRequest: GraphQLRequest? + + override func fetch(request: RESTRequest) async throws -> HTTPResponse { + capturedRESTRequest = request + + if let stubHTTPError { + throw stubHTTPError + } - override func fetch(request: T) async throws -> (T.ResponseType) { - if let cannedFetchError { - throw cannedFetchError + if let stubHTTPResponse { + return stubHTTPResponse } - let cannedData = cannedJSONResponse!.data(using: String.Encoding.utf8)! - return try HTTPResponseParser().parse( - HTTPResponse(status: 200, body: cannedData), - as: T.ResponseType.self - ) + + throw CoreSDKError(code: 0, domain: "", errorDescription: "Stubbed responses not implemented for this mock.") + } + + override func fetch(request: GraphQLRequest) async throws -> HTTPResponse { + capturedGraphQLRequest = request + + if let stubHTTPError { + throw stubHTTPError + } + + if let stubHTTPResponse { + return stubHTTPResponse + } + + throw CoreSDKError(code: 0, domain: "", errorDescription: "Stubbed responses not implemented for this mock.") } } diff --git a/UnitTests/TestShared/MockHTTP.swift b/UnitTests/TestShared/MockHTTP.swift index 53384fdd4..bf2e9a53b 100644 --- a/UnitTests/TestShared/MockHTTP.swift +++ b/UnitTests/TestShared/MockHTTP.swift @@ -2,28 +2,23 @@ import Foundation class MockHTTP: HTTP { - - var lastPOSTParameters: [String: Any]? - var lastAPIRequest: (any APIRequest)? - + var stubHTTPResponse: HTTPResponse? var stubHTTPError: Error? - init(coreConfig: CoreConfig = CoreConfig(clientID: "fake-client-id", environment: .sandbox)) { - super.init(coreConfig: coreConfig) - } + var capturedHTTPRequest: HTTPRequest? - override func performRequest(_ request: any APIRequest) async throws -> HTTPResponse { - lastAPIRequest = request - - if let body = request.body { - lastPOSTParameters = try JSONSerialization.jsonObject(with: body, options: []) as? [String: Any] - } + override func performRequest(_ httpRequest: HTTPRequest) async throws -> HTTPResponse { + capturedHTTPRequest = httpRequest if let stubHTTPError { throw stubHTTPError - } else { - return stubHTTPResponse ?? HTTPResponse(status: 200, body: nil) } + + if let stubHTTPResponse { + return stubHTTPResponse + } + + throw CoreSDKError(code: 0, domain: "", errorDescription: "Stubbed responses not implemented for this mock.") } } diff --git a/UnitTests/TestShared/MockURLSession.swift b/UnitTests/TestShared/MockURLSession.swift index 30476169e..9a3aafa13 100644 --- a/UnitTests/TestShared/MockURLSession.swift +++ b/UnitTests/TestShared/MockURLSession.swift @@ -7,7 +7,11 @@ class MockURLSession: URLSessionProtocol { var cannedURLResponse: URLResponse? var cannedJSONData: String? + var capturedURLRequest: URLRequest? + func performRequest(with urlRequest: URLRequest) async throws -> (Data, URLResponse) { + capturedURLRequest = urlRequest + if let error = cannedError { throw error } else {