diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5ba7954..b16e2fe 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -79,7 +79,8 @@ jobs: android-unit: runs-on: ubuntu-latest - + env: + ORG_GRADLE_PROJECT_org_gradle_jvmargs: -Xmx2g steps: - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1f4cee4..97730d5 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,7 +1,7 @@ PODS: - boost (1.76.0) - DoubleConversion (1.1.6) - - Factory (2.4.3) + - Factory (2.5.2) - FBLazyVector (0.72.7) - FBReactNativeSpec (0.72.7): - RCT-Folly (= 2021.07.22.00) @@ -439,7 +439,7 @@ PODS: - React-Core - SocketRocket (0.6.1) - SwiftyBeaver (2.1.1) - - tyro-pay-api-react-native (1.0.2): + - tyro-pay-api-react-native (2.0.0): - RCT-Folly (= 2021.07.22.00) - React-Core - TyroApplePay (= 1.0.0) @@ -600,36 +600,36 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7dcd2de282d72e344012f7d6564d024930a6a440 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 - Factory: 9d20d8cb56fc44b341d6dad1cf67dc93e9ae7788 + Factory: bfc0d3ec51e8f1b0c91e715ea9b541bb2e1a6f85 FBLazyVector: 5fbbff1d7734827299274638deb8ba3024f6c597 FBReactNativeSpec: 638095fe8a01506634d77b260ef8a322019ac671 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b hermes-engine: 9180d43df05c1ed658a87cc733dc3044cf90c00a libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 - RCT-Folly: 8dc08ca5a393b48b1c523ab6220dfdcc0fe000ad + RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: 83bca1c184feb4d2e51c72c8369b83d641443f95 RCTTypeSafety: 13c4a87a16d7db6cd66006ce9759f073402ef85b React: e67aa9f99957c7611c392b5e49355d877d6525e2 React-callinvoker: 2790c09d964c2e5404b5410cde91b152e3746b7b - React-Codegen: 89173b1974099c3082e50c83e9d04113ede45792 - React-Core: 27990a32ca0cfc04872600440f618365b7c35433 - React-CoreModules: 2a1850a46d60b901cceef4e64bcf5bf6a0130206 - React-cxxreact: 03d370d58a083a1c8b5a69b9095c1ac9f57b2f94 + React-Codegen: e6e05e105ca7cdb990f4d609985a2a689d8d0653 + React-Core: 9283f1e7d0d5e3d33ad298547547b1b43912534c + React-CoreModules: 6312c9b2fec4329d9ae6a2b8c350032d1664c51b + React-cxxreact: 7da72565656c8ac7f97c9a031d0b199bbdec0640 React-debug: 4accb2b9dc09b575206d2c42f4082990a52ae436 - React-hermes: 0a9e25fbf4dbcd8ca89de9a89a0cce2fce45989f - React-jsi: 0c473d4292f9a10469b3755767bf28d0b35fbeb6 - React-jsiexecutor: 00fdf7bd0e99ab878109ce1b51cb6212d76683e4 + React-hermes: 1299a94f255f59a72d5baa54a2ca2e1eee104947 + React-jsi: 2208de64c3a41714ac04e86975386fc49116ea13 + React-jsiexecutor: c49502e5d02112247ee4526bc3ccfc891ae3eb9b React-jsinspector: 8baadae51f01d867c3921213a25ab78ab4fbcd91 - React-logger: 61efd44da84482aabbbbb478a49b893c7c912f99 - react-native-safe-area-context: 8c70551c8688cd584a53487aa1b9361e991a3b4a - react-native-webview: 6ef00afa766bf2d4cc6c2705be3add7b38d604f3 - React-NativeModulesApple: 2f7a355e9b4c83b9509bf6dd798dc5f63ab8bc7d + React-logger: 8edc785c47c8686c7962199a307015e2ce9a0e4f + react-native-safe-area-context: dcab599c527c2d7de2d76507a523d20a0b83823d + react-native-webview: 4e7d637b43eddec107016d316ae75f7063a3075c + React-NativeModulesApple: b6868ee904013a7923128892ee4a032498a1024a React-perflogger: 31ea61077185eb1428baf60c0db6e2886f141a5a React-RCTActionSheet: 392090a3abc8992eb269ef0eaa561750588fc39d React-RCTAnimation: 4b3cc6a29474bc0d78c4f04b52ab59bf760e8a9b - React-RCTAppDelegate: b6febbe1109554fee87d3fea1c50cca511429fec - React-RCTBlob: 76113160e3cdc0f678795823c1a7c9d69b2db099 + React-RCTAppDelegate: 89b015b29885109addcabecdf3b2e833905437c7 + React-RCTBlob: 3e23dcbe6638897b5605e46d0d62955d78e8d27b React-RCTImage: 8a5d339d614a90a183fc1b8b6a7eb44e2e703943 React-RCTLinking: b37dfbf646d77c326f9eae094b1fcd575b1c24c7 React-RCTNetwork: 8bed9b2461c7d8a7d14e63df9b16181c448beebc @@ -638,17 +638,17 @@ SPEC CHECKSUMS: React-RCTVibration: d1b78ca38f61ea4b3e9ebb2ddbd0b5662631d99b React-rncore: bfc2f6568b6fecbae6f2f774e95c60c3c9e95bf2 React-runtimeexecutor: 47b0a2d5bbb416db65ef881a6f7bdcfefa0001ab - React-runtimescheduler: d12a963f61390fcd1b957a9c9ebee3c0f775dede - React-utils: 22f94a6e85b1323ffb1b9a747a1c03c5e6eaead6 - ReactCommon: ef602e9cfb8940ad7c08aa4cdc228d802e194e5c - RNScreens: 448026fcd1beb88770b0a67a871a3d1bf9cdde0a - RNSVG: 6d5ed33b6635ed6d6ecb50744dcf127580c39ed5 + React-runtimescheduler: 7649c3b46c8dee1853691ecf60146a16ae59253c + React-utils: 56838edeaaf651220d1e53cd0b8934fb8ce68415 + ReactCommon: 5f704096ccf7733b390f59043b6fa9cc180ee4f6 + RNScreens: 93ae3be2f119d955620f9bbb39ad372adb53b7a9 + RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 SwiftyBeaver: ade157e4f857812e7d7f15f2e3396bb8733f8a1c - tyro-pay-api-react-native: 1f0b713a4171442686ef9e9d7fd23ffe46aa8c95 + tyro-pay-api-react-native: 723baf9bd8ac82f035fb62ad44e69c6fdc8f5ce9 TyroApplePay: ac775ba6a496cb2a89816f0ccee8a4515b321851 Yoga: 4c3aa327e4a6a23eeacd71f61c81df1bcdf677d5 -PODFILE CHECKSUM: 8bcc1b808214f11bdc1a3c6c79b0c5bf4213233e +PODFILE CHECKSUM: c25f6fa4d193254fc4c874c3c309e3cbe447f14e -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/example/package-lock.json b/example/package-lock.json index 327cc76..898dfa3 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -41,7 +41,7 @@ }, "..": { "name": "@tyro/tyro-pay-api-react-native", - "version": "1.0.2", + "version": "2.0.0", "devDependencies": { "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", diff --git a/example/src/Checkout.tsx b/example/src/Checkout.tsx index 3516a5b..49db7c8 100644 --- a/example/src/Checkout.tsx +++ b/example/src/Checkout.tsx @@ -136,6 +136,7 @@ const styles = StyleSheet.create({ marginTop: 20, fontSize: 32, fontWeight: 'bold', + color: 'black', }, }); diff --git a/ios/TyroPaySdkModule.swift b/ios/TyroPaySdkModule.swift index a245b3b..689cdc3 100644 --- a/ios/TyroPaySdkModule.swift +++ b/ios/TyroPaySdkModule.swift @@ -39,10 +39,11 @@ class TyroPaySdkModule: RCTEventEmitter { guard TyroApplePay.isApplePayAvailable() else { resolve(false) return - } - let merchantIdentifier: String = try getConfigParamOrThrow("merchantIdentifier", configs, "Merchant Identifier is required") - let merchantName: String = try getConfigParamOrThrow("totalLabel", configs, "Merchant Name is required") - let allowedCardNetworks: [String] = try getConfigParamOrThrow("supportedNetworks", configs, "Supported Networks is require") + } + let applePayConfigs: NSDictionary = try getConfigParamOrThrow("applePay", configs, "applePay config is required") + let merchantIdentifier: String = try getConfigParamOrThrow("merchantIdentifier", applePayConfigs, "Merchant Identifier is required") + let merchantName: String = try getConfigParamOrThrow("totalLabel", applePayConfigs, "Merchant Name is required") + let allowedCardNetworks: [String] = try getConfigParamOrThrow("supportedNetworks", applePayConfigs, "Supported Networks is require") let paymentNetworks: [TyroApplePayCardNetwork] = try self.mapSupportedNetworkStringsToTyroApplePayCardNetwork(allowedCardNetworks) self.config = TyroApplePay.Configuration( diff --git a/src/@types/default.ts b/src/@types/default.ts index 72bc44e..9afd506 100644 --- a/src/@types/default.ts +++ b/src/@types/default.ts @@ -14,6 +14,7 @@ export const defaultOptions = { options: { applePay: { enabled: false, + supportedNetworks: ['mastercard', 'visa', 'amex', 'jcb', 'maestro'], }, googlePay: { enabled: false, diff --git a/src/TyroSDK.ts b/src/TyroSDK.ts index c302d29..f4f782a 100644 --- a/src/TyroSDK.ts +++ b/src/TyroSDK.ts @@ -1,10 +1,11 @@ import { getPayRequest } from './clients/pay-request-client'; import { ClientPayRequestResponse, PayRequestStatus } from './@types/pay-request-types'; import { TyroPayOptions, TyroPayOptionsKeys } from './@types/definitions'; -import { NativeModules } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; import { WalletPaymentInitResult, WalletPaymentResult } from './@types/wallet-payment-result'; import { ErrorCodes } from './@types/error-message-types'; import { PaySheetInitError } from './@types/sdk-errors/pay-sheet-init-error'; +import { isAndroid, isiOS } from './utils/helpers'; const { TyroPaySdkModule } = NativeModules; @@ -35,23 +36,24 @@ class TyroSDK { const walletPaymentConfigs = options?.[TyroPayOptionsKeys.options]; const liveMode = options[TyroPayOptionsKeys.liveMode]; let walletConfig = {}; - if (walletPaymentConfigs?.googlePay?.enabled) { + // Only proceed if the relevant wallet pay is enabled for the platform + if ( + (isiOS(Platform.OS) && walletPaymentConfigs?.applePay?.enabled) || + (isAndroid(Platform.OS) && walletPaymentConfigs?.googlePay?.enabled) + ) { walletConfig = { googlePay: { ...walletPaymentConfigs.googlePay, liveMode }, + applePay: { ...walletPaymentConfigs.applePay, liveMode }, }; - } else if (walletPaymentConfigs?.applePay?.enabled) { - walletConfig = { - ...options.options.applePay, - liveMode, - }; - } - try { - const paymentSupported = await TyroPaySdkModule.initWalletPay(walletConfig); - return { - paymentSupported, - }; - } catch (error) { - throw new PaySheetInitError(ErrorCodes.WALLET_INIT_FAILED); + try { + const paymentSupported = await TyroPaySdkModule.initWalletPay(walletConfig); + return { paymentSupported }; + } catch (error) { + throw new PaySheetInitError(ErrorCodes.WALLET_INIT_FAILED); + } + } else { + // If not enabled, just return paymentSupported: false + return { paymentSupported: false }; } }; diff --git a/src/TyroSharedContext.tsx b/src/TyroSharedContext.tsx index bfb2a86..3bb5fb6 100644 --- a/src/TyroSharedContext.tsx +++ b/src/TyroSharedContext.tsx @@ -89,8 +89,15 @@ const TyroProvider = ({ children, options }: TyroPayContext): JSX.Element => { ); }; + const missingTotalLabel = (options: TyroPayOptionsOptionsProps): boolean => { + return !!( + options[TyroPayOptionsOptionsKeys.applePay]?.[TyroPayApplePayOptionKeys.enabled] && + !options[TyroPayOptionsOptionsKeys.applePay]?.[TyroPayApplePayOptionKeys.totalLabel] + ); + }; + const missingMerchantDetails = (options: TyroPayOptionsOptionsProps): boolean => { - return missingMerchantIdentifier(options) || missingMerchantName(options); + return missingMerchantIdentifier(options) || missingMerchantName(options) || missingTotalLabel(options); }; const initProvider = (cleanedOptions: TyroPayOptions): void => { diff --git a/src/tests/PaySheet.spec.tsx b/src/tests/PaySheet.spec.tsx index 2ed3829..554d06c 100644 --- a/src/tests/PaySheet.spec.tsx +++ b/src/tests/PaySheet.spec.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import TyroProvider from '../TyroSharedContext'; +import React from 'react'; import { NativeModules } from 'react-native'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { ClientPayRequestResponse, PayRequestStatus, ThreeDSecureStatus } from '../@types/pay-request-types'; diff --git a/src/tests/TyroSDK.spec.ts b/src/tests/TyroSDK.spec.ts index c220bd1..0ddb0e4 100644 --- a/src/tests/TyroSDK.spec.ts +++ b/src/tests/TyroSDK.spec.ts @@ -1,4 +1,5 @@ import tyroSdk from '../TyroSDK'; +import { NativeModules } from 'react-native'; import { CaptureMethod, ClientPayRequestResponse, @@ -7,6 +8,8 @@ import { } from '../@types/pay-request-types'; import { PaySheetInitError } from '../@types/sdk-errors/pay-sheet-init-error'; import { ErrorCodes } from '../@types/error-message-types'; +import { TyroPayOptions } from '../@types/definitions'; +import { isAndroid, isiOS } from '../utils/helpers'; const mockFetch = async (status: number, payload: ClientPayRequestResponse): Promise => { return { @@ -15,6 +18,11 @@ const mockFetch = async (status: number, payload: ClientPayRequestResponse): Pro } as Response; }; +jest.mock('../utils/helpers', () => ({ + isAndroid: jest.fn(), + isiOS: jest.fn(), +})); + global.fetch = jest.fn(() => mockFetch(200, { origin: { @@ -47,83 +55,182 @@ global.fetch = jest.fn(() => ); describe('TyroSDK', () => { - beforeAll(() => { + beforeEach(() => { jest.clearAllMocks(); }); - it('inits and verifies the pay secret and returns the PayRequestSimplifiedStatus', async () => { - await expect(tyroSdk.initPaySheet('secret', false)).resolves.toEqual({ - errorMessage: 'no error message', - errorCode: 'no error code', - origin: { - orderId: 'some string', - orderReference: 'some string', - name: 'some name', - }, - status: 'AWAITING_PAYMENT_INPUT', - threeDSecure: { - challengeURL: 'url', - methodURL: 'url', - status: 'AWAITING_CHALLENGE', - }, - total: { - amount: 100, - currency: 'AUD', - }, - gatewayCode: '123', - isLive: false, - capture: { - method: CaptureMethod.AUTOMATIC, + describe('initPaySheet', () => { + it('inits and verifies the pay secret and returns the PayRequestSimplifiedStatus', async () => { + await expect(tyroSdk.initPaySheet('secret', false)).resolves.toEqual({ + errorMessage: 'no error message', + errorCode: 'no error code', + origin: { + orderId: 'some string', + orderReference: 'some string', + name: 'some name', + }, + status: 'AWAITING_PAYMENT_INPUT', + threeDSecure: { + challengeURL: 'url', + methodURL: 'url', + status: 'AWAITING_CHALLENGE', + }, total: { amount: 100, currency: 'AUD', }, - }, + gatewayCode: '123', + isLive: false, + capture: { + method: CaptureMethod.AUTOMATIC, + total: { + amount: 100, + currency: 'AUD', + }, + }, + }); }); - }); - it('throws an error if there was an environment mismatch', async () => { - await expect(tyroSdk.initPaySheet('secret', true)).rejects.toThrowError( - new PaySheetInitError(ErrorCodes.ENVIRONMENT_MISMATCH) - ); - }); + it('throws an error if there was an environment mismatch', async () => { + await expect(tyroSdk.initPaySheet('secret', true)).rejects.toThrowError( + new PaySheetInitError(ErrorCodes.ENVIRONMENT_MISMATCH) + ); + }); - it('throws an error if the pay request was already submitted successfully', async () => { - global.fetch = jest.fn(() => mockFetch(200, { status: 'SUCCESS', isLive: false } as ClientPayRequestResponse)); - await expect(tyroSdk.initPaySheet('secret', false)).rejects.toThrowError( - new PaySheetInitError(ErrorCodes.PAY_REQUEST_INVALID_STATUS) - ); - }); + it('throws an error if the pay request was already submitted successfully', async () => { + global.fetch = jest.fn(() => mockFetch(200, { status: 'SUCCESS', isLive: false } as ClientPayRequestResponse)); + await expect(tyroSdk.initPaySheet('secret', false)).rejects.toThrowError( + new PaySheetInitError(ErrorCodes.PAY_REQUEST_INVALID_STATUS) + ); + }); - it('throws an error if the pay secret is invalid', async () => { - global.fetch = jest.fn(() => - mockFetch(403, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) - ); - try { - await tyroSdk.initPaySheet('secret', false); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect(error).toHaveProperty('status', '403'); - expect(error.message).toBe('Http Status Error'); - } - }); + it('throws an error if the pay secret is invalid', async () => { + global.fetch = jest.fn(() => + mockFetch(403, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) + ); + try { + await tyroSdk.initPaySheet('secret', false); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty('status', '403'); + expect(error.message).toBe('Http Status Error'); + } + }); - it('throws an error if the something went wrong with fetching pay request', async () => { - global.fetch = jest.fn(() => - mockFetch(500, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) - ); - try { - await tyroSdk.initPaySheet('secret', false); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect(error).toHaveProperty('status', '500'); - expect(error.message).toBe('Http Status Error'); - } + it('throws an error if the something went wrong with fetching pay request', async () => { + global.fetch = jest.fn(() => + mockFetch(500, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) + ); + try { + await tyroSdk.initPaySheet('secret', false); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty('status', '500'); + expect(error.message).toBe('Http Status Error'); + } + }); + + it('throws an error if the pay secret is an empty string', async () => { + await expect(tyroSdk.initPaySheet('', false)).rejects.toThrowError( + new PaySheetInitError(ErrorCodes.NO_PAY_SECRET) + ); + }); }); - it('throws an error if the pay secret is an empty string', async () => { - await expect(tyroSdk.initPaySheet('', false)).rejects.toThrowError( - new PaySheetInitError(ErrorCodes.PAY_REQUEST_SECRET_REQUIRED) - ); + describe('initWalletPay', () => { + it('initializes wallet pay for Google Pay and Apple Pay on Android', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); + const options = { + options: { + googlePay: { enabled: true, liveMode: false }, + applePay: { enabled: true, liveMode: false }, + }, + liveMode: false, + } as unknown as TyroPayOptions; + const result = await tyroSdk.initWalletPay(options); + expect(result).toEqual({ paymentSupported: true }); + expect(NativeModules.TyroPaySdkModule.initWalletPay).toHaveBeenCalledWith({ + applePay: { + enabled: true, + liveMode: false, + }, + googlePay: { + enabled: true, + liveMode: false, + }, + }); + }); + it('initializes wallet pay for Google Pay and Apple Pay on iOS', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); + const options = { + options: { + googlePay: { enabled: true, liveMode: false }, + applePay: { enabled: true, liveMode: false }, + }, + liveMode: false, + } as unknown as TyroPayOptions; + const result = await tyroSdk.initWalletPay(options); + expect(result).toEqual({ paymentSupported: true }); + expect(NativeModules.TyroPaySdkModule.initWalletPay).toHaveBeenCalledWith({ + applePay: { + enabled: true, + liveMode: false, + }, + googlePay: { + enabled: true, + liveMode: false, + }, + }); + }); + it('does not call native module to init wallet if android and google pay disabled', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); + const options = { + options: { + googlePay: { enabled: false, liveMode: false }, + applePay: { enabled: true, liveMode: false }, + }, + liveMode: false, + } as unknown as TyroPayOptions; + const result = await tyroSdk.initWalletPay(options); + expect(result).toEqual({ paymentSupported: false }); + expect(NativeModules.TyroPaySdkModule.initWalletPay).not.toHaveBeenCalled(); + }); + it('does not call native module to init wallet if ios and apple pay disabled', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); + const options = { + options: { + googlePay: { enabled: true, liveMode: false }, + applePay: { enabled: false, liveMode: false }, + }, + liveMode: false, + } as unknown as TyroPayOptions; + const result = await tyroSdk.initWalletPay(options); + expect(result).toEqual({ paymentSupported: false }); + expect(NativeModules.TyroPaySdkModule.initWalletPay).not.toHaveBeenCalled(); + }); + it('throws init error when native module throws an error', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockRejectedValueOnce(new Error('boom')); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); + const options = { + options: { + googlePay: { enabled: true, liveMode: false }, + applePay: { enabled: false, liveMode: false }, + }, + liveMode: false, + } as unknown as TyroPayOptions; + await expect(tyroSdk.initWalletPay(options)).rejects.toThrowError( + new PaySheetInitError(ErrorCodes.WALLET_INIT_FAILED) + ); + expect(NativeModules.TyroPaySdkModule.initWalletPay).toHaveBeenCalled(); + }); }); }); diff --git a/src/tests/TyroSharedContext.spec.tsx b/src/tests/TyroSharedContext.spec.tsx index d43c9db..87c6df9 100644 --- a/src/tests/TyroSharedContext.spec.tsx +++ b/src/tests/TyroSharedContext.spec.tsx @@ -1,7 +1,7 @@ import TyroProvider from '../TyroSharedContext'; import React from 'react'; import { NativeModules } from 'react-native'; -import * as helpers from '../utils/helpers'; +import { isAndroid, isiOS } from '../utils/helpers'; import { render, fireEvent, waitFor, cleanup } from '@testing-library/react-native'; import { ClientPayRequestResponse } from '../@types/pay-request-types'; import { mockFetch } from './utils/mocks'; @@ -11,16 +11,10 @@ import { TyroPayOptionsProps } from '../@types/definitions'; import { ErrorCodes, TyroErrorMessages } from '../@types/error-message-types'; import { HTTP_FORBIDDEN, HTTP_OK, HTTP_SERVICE_UNAVAILABLE } from '../@types/http-status-codes'; -jest.mock('../utils/helpers', () => { - return { - __esModule: true, - ...jest.requireActual('../utils/helpers'), - isAndroid: jest.fn(), - isiOS: jest.fn(), - }; -}); - -const mockedHelpers = helpers as jest.Mocked; +jest.mock('../utils/helpers', () => ({ + isAndroid: jest.fn(), + isiOS: jest.fn(), +})); const renderWithProvider = async (component, options: TyroPayOptionsProps): Promise => { return render({component}); @@ -30,6 +24,7 @@ let wrapper; const merchantIdentifier = 'merId'; const merchantName = 'merName'; +const totalLabel = 'Total Label'; describe('TyroProvider', () => { describe('init TyroProvider', () => { @@ -40,8 +35,8 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(true); - mockedHelpers.isiOS.mockReturnValue(false); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -62,13 +57,13 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(false); - mockedHelpers.isiOS.mockReturnValue(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { liveMode: false, - options: { applePay: { enabled: true } }, + options: { applePay: { enabled: true, totalLabel } }, }); }); expect(await wrapper.findByText(`ErrorCode: ${ErrorCodes.MISSING_MERCHANT_CONFIG}`)).not.toBeNull(); @@ -80,12 +75,12 @@ describe('TyroProvider', () => { }); }, 15000); - test('TyroProvider does initialise when applePay enabled with merchantIdentifier', async () => { + test('TyroProvider does not initialise when applePay enabled and missing totalLabel', async () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(false); - mockedHelpers.isiOS.mockReturnValue(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -93,6 +88,28 @@ describe('TyroProvider', () => { options: { applePay: { enabled: true, merchantIdentifier } }, }); }); + expect(await wrapper.findByText(`ErrorCode: ${ErrorCodes.MISSING_MERCHANT_CONFIG}`)).not.toBeNull(); + expect(wrapper.queryByText('Or pay with card')).toBeNull(); + expect(wrapper.queryByPlaceholderText('Card number')).toBeNull(); + expect(wrapper.queryByPlaceholderText('Name on card')).toBeNull(); + expect(wrapper.queryByPlaceholderText('MM/YY')).toBeNull(); + expect(wrapper.queryByPlaceholderText('CVV')).toBeNull(); + }); + }, 15000); + + test('TyroProvider does initialise when applePay enabled with merchantIdentifier and totalLabel', async () => { + global.fetch = jest.fn(() => + mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) + ); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); + await act(async () => { + await waitFor(async () => { + wrapper = await renderWithProvider(, { + liveMode: false, + options: { applePay: { enabled: true, merchantIdentifier, totalLabel } }, + }); + }); expect(await wrapper.queryByText(`ErrorCode: ${ErrorCodes.MISSING_MERCHANT_CONFIG}`)).toBeNull(); expect(wrapper.queryByText('Or pay with card')).toBeNull(); expect(wrapper.queryByPlaceholderText('Card number')).toBeNull(); @@ -106,8 +123,8 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(false); - mockedHelpers.isiOS.mockReturnValue(true); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -128,15 +145,15 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(false); - mockedHelpers.isiOS.mockReturnValue(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { liveMode: false, options: { googlePay: { enabled: true, merchantName }, - applePay: { enabled: true, merchantIdentifier }, + applePay: { enabled: true, merchantIdentifier, totalLabel }, }, }); }); @@ -152,15 +169,15 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(true); - mockedHelpers.isiOS.mockReturnValue(false); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { liveMode: false, options: { googlePay: { enabled: true, merchantName }, - applePay: { enabled: true, merchantIdentifier }, + applePay: { enabled: true, merchantIdentifier, totalLabel }, }, }); }); @@ -173,12 +190,12 @@ describe('TyroProvider', () => { }); }, 15000); - test('TyroProvider does initialise when googlePay/applePay enabled and merchantIdentifier missing for apple pay on android', async () => { + test('TyroProvider does initialise when googlePay/applePay enabled and merchantIdentifier and totalLabel missing for apple pay on android', async () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(true); - mockedHelpers.isiOS.mockReturnValue(false); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -202,15 +219,15 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(false); - mockedHelpers.isiOS.mockReturnValue(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { liveMode: false, options: { googlePay: { enabled: true }, - applePay: { enabled: true, merchantIdentifier }, + applePay: { enabled: true, merchantIdentifier, totalLabel }, }, }); }); @@ -223,12 +240,33 @@ describe('TyroProvider', () => { }); }, 15000); - test('TyroProvider does initialise when googlePay/applePay disabled', async () => { + test('TyroProvider does initialise when googlePay/applePay disabled on android', async () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValue(true); - mockedHelpers.isiOS.mockReturnValue(false); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); + await act(async () => { + await waitFor(async () => { + wrapper = await renderWithProvider(, { + liveMode: false, + }); + }); + expect(await wrapper.queryByText(`ErrorCode: ${ErrorCodes.MISSING_MERCHANT_CONFIG}`)).toBeNull(); + expect(wrapper.queryByText('Or pay with card')).toBeNull(); + expect(wrapper.queryByPlaceholderText('Card number')).toBeNull(); + expect(wrapper.queryByPlaceholderText('Name on card')).toBeNull(); + expect(wrapper.queryByPlaceholderText('MM/YY')).toBeNull(); + expect(wrapper.queryByPlaceholderText('CVV')).toBeNull(); + }); + }, 15000); + + test('TyroProvider does initialise when googlePay/applePay disabled on ios', async () => { + global.fetch = jest.fn(() => + mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) + ); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -253,8 +291,8 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValueOnce(true); - mockedHelpers.isiOS.mockReturnValueOnce(false); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -264,10 +302,6 @@ describe('TyroProvider', () => { enabled: true, merchantName, }, - applePay: { - enabled: true, - merchantIdentifier, - }, }, }); }); @@ -284,13 +318,13 @@ describe('TyroProvider', () => { }); }, 15000); - test('Able to init and display PaySheet for iOS', async () => { + test('Able to init and display just google pay for android', async () => { NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValueOnce(false); - mockedHelpers.isiOS.mockReturnValueOnce(true); + (isAndroid as jest.Mock).mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(false); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -300,9 +334,45 @@ describe('TyroProvider', () => { enabled: true, merchantName, }, + creditCardForm: { + enabled: false, + }, + }, + styleProps: { + walletPaymentsDividerEnabled: false, + }, + }); + }); + expect(wrapper.queryByText('Pay')).toBeNull(); + expect(wrapper.queryByText('Or pay with card')).toBeNull(); + const button = await wrapper.findByTestId('test-button'); + await fireEvent.press(button); + expect(await wrapper.findByText('Pay')).not.toBeNull(); + expect(await wrapper.queryByText('Or pay with card')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('Card number')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('Name on card')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('MM/YY')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('CVV')).toBeNull(); + expect(wrapper.queryByTestId('google-pay-button')).not.toBeNull(); + }); + }, 15000); + + test('Able to init and display PaySheet for iOS', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); + global.fetch = jest.fn(() => + mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) + ); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); + await act(async () => { + await waitFor(async () => { + wrapper = await renderWithProvider(, { + liveMode: false, + options: { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, }); @@ -320,6 +390,46 @@ describe('TyroProvider', () => { }); }, 15000); + test('Able to init and display just Apple Pay for iOS', async () => { + NativeModules.TyroPaySdkModule.initWalletPay.mockResolvedValue(true); + global.fetch = jest.fn(() => + mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) + ); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); + await act(async () => { + await waitFor(async () => { + wrapper = await renderWithProvider(, { + liveMode: false, + options: { + applePay: { + enabled: true, + merchantIdentifier, + totalLabel, + }, + creditCardForm: { + enabled: false, + }, + }, + styleProps: { + walletPaymentsDividerEnabled: false, + }, + }); + }); + expect(wrapper.queryByText('Pay')).toBeNull(); + expect(wrapper.queryByText('Or pay with card')).toBeNull(); + const button = await wrapper.findByTestId('test-button'); + await fireEvent.press(button); + expect(await wrapper.findByText('Pay')).not.toBeNull(); + expect(await wrapper.queryByText('Or pay with card')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('Card number')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('Name on card')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('MM/YY')).toBeNull(); + expect(await wrapper.queryByPlaceholderText('CVV')).toBeNull(); + expect(await wrapper.findByTestId('apple-pay-button')).not.toBeNull(); + }); + }, 15000); + test('PaySheet is not displayed and there is an error when the pay request has an invalid status', async () => { global.fetch = jest.fn(() => mockFetch(200, { status: 'SUCCESS', isLive: false } as ClientPayRequestResponse)); await act(async () => { @@ -396,12 +506,13 @@ describe('TyroProvider', () => { }, 15000); test('PaySheet is not displayed when there is an error initialising the wallet', async () => { + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); NativeModules.TyroPaySdkModule.initWalletPay.mockRejectedValueOnce(new Error('Error')); global.fetch = jest.fn(() => mockFetch(200, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValueOnce(false); - mockedHelpers.isiOS.mockReturnValueOnce(true); + await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -414,6 +525,7 @@ describe('TyroProvider', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, }); @@ -445,8 +557,8 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => mockFetch(HTTP_FORBIDDEN, { status: 'AWAITING_PAYMENT_INPUT', isLive: false } as ClientPayRequestResponse) ); - mockedHelpers.isAndroid.mockReturnValueOnce(false); - mockedHelpers.isiOS.mockReturnValueOnce(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -459,6 +571,7 @@ describe('TyroProvider', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, }); @@ -486,8 +599,8 @@ describe('TyroProvider', () => { global.fetch = jest.fn(() => { throw new Error('Fetch Error'); }); - mockedHelpers.isAndroid.mockReturnValueOnce(false); - mockedHelpers.isiOS.mockReturnValueOnce(true); + (isAndroid as jest.Mock).mockReturnValue(false); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { @@ -500,6 +613,7 @@ describe('TyroProvider', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, }); @@ -550,7 +664,7 @@ describe('TyroProvider', () => { cleanup(); }); test('Provider is able to provide the default options to its children on Android', async () => { - mockedHelpers.isAndroid.mockReturnValue(true); + (isAndroid as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { liveMode: false }); @@ -563,7 +677,7 @@ describe('TyroProvider', () => { }); }, 15000); test('Provider is able to provide the default options to its children on iOS', async () => { - mockedHelpers.isiOS.mockReturnValue(true); + (isiOS as jest.Mock).mockReturnValue(true); await act(async () => { await waitFor(async () => { wrapper = await renderWithProvider(, { liveMode: true }); diff --git a/src/tests/WalletPaymentsContainer.spec.tsx b/src/tests/WalletPaymentsContainer.spec.tsx index 44aee03..069592a 100644 --- a/src/tests/WalletPaymentsContainer.spec.tsx +++ b/src/tests/WalletPaymentsContainer.spec.tsx @@ -39,6 +39,7 @@ const mockedFailedResult = { const merchantIdentifier = 'merId'; const merchantName = 'merName'; +const totalLabel = 'Total Label'; describe('WalletPaymentsContainer', () => { let wrapper; @@ -354,6 +355,7 @@ describe('WalletPaymentsContainer', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, styleProps: { showSupportedCards: false }, @@ -367,6 +369,60 @@ describe('WalletPaymentsContainer', () => { const button = await wrapper.findByTestId('apple-pay-button'); expect(button._fiber.memoizedProps.buttonStyle).toEqual('black'); expect(button._fiber.memoizedProps.buttonLabel).toEqual('plain'); + expect(NativeModules.TyroPaySdkModule.initWalletPay).toHaveBeenCalledWith({ + applePay: { + enabled: true, + liveMode: false, + merchantIdentifier: 'merId', + supportedNetworks: ['amex', 'jcb', 'mastercard', 'visa', 'maestro'], + totalLabel: 'Total Label', + }, + googlePay: { + enabled: false, + liveMode: false, + supportedNetworks: ['amex', 'jcb', 'mastercard', 'visa'], + }, + }); + }, 15000); + + test('should pass supportedNetworks to native module when defined in apple pay options', async () => { + await act(async () => { + await waitFor(async () => { + wrapper = await renderWithProvider(, { + liveMode: false, + options: { + applePay: { + enabled: true, + merchantIdentifier, + totalLabel, + supportedNetworks: ['visa', 'mastercard'], + }, + }, + styleProps: { showSupportedCards: false }, + }); + }); + // check initial components have rendered, click checkout + const checkOutButton = await wrapper.findByTestId('test-button'); + await fireEvent.press(checkOutButton); + }); + // check apple pay button + const button = await wrapper.findByTestId('apple-pay-button'); + expect(button._fiber.memoizedProps.buttonStyle).toEqual('black'); + expect(button._fiber.memoizedProps.buttonLabel).toEqual('plain'); + expect(NativeModules.TyroPaySdkModule.initWalletPay).toHaveBeenCalledWith({ + applePay: { + enabled: true, + liveMode: false, + merchantIdentifier: 'merId', + supportedNetworks: ['mastercard', 'visa'], + totalLabel: 'Total Label', + }, + googlePay: { + enabled: false, + liveMode: false, + supportedNetworks: ['amex', 'jcb', 'mastercard', 'visa'], + }, + }); }, 15000); test('should do nothing when applePay is cancelled', async () => { NativeModules.TyroPaySdkModule.startWalletPay.mockResolvedValue(mockedCancelledResult); @@ -379,6 +435,7 @@ describe('WalletPaymentsContainer', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, styleProps: { showSupportedCards: false }, @@ -409,6 +466,7 @@ describe('WalletPaymentsContainer', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, styleProps: { showSupportedCards: false }, @@ -443,6 +501,7 @@ describe('WalletPaymentsContainer', () => { applePay: { enabled: true, merchantIdentifier, + totalLabel, }, }, styleProps: { showSupportedCards: false },