@rnw-community/react-native-payments
Version:
Accept Payments with Apple Pay and Android Pay using the Payment Request API.
692 lines (566 loc) • 28.5 kB
text/typescript
/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { Platform } from 'react-native';
import { IosPKPaymentMethodType } from '../../@standard/ios/enum/ios-pk-payment-method-type.enum';
import { EnvironmentEnum } from '../../enum/environment.enum';
import { PaymentMethodNameEnum } from '../../enum/payment-method-name.enum';
import { PaymentsErrorEnum } from '../../enum/payments-error.enum';
import { SupportedNetworkEnum } from '../../enum/supported-networks.enum';
import { ConstructorError } from '../../error/constructor.error';
import { DOMException } from '../../error/dom.exception';
import { PaymentsError } from '../../error/payments.error';
import { NativePayments } from '../native-payments/native-payments';
import { PaymentRequest } from './payment-request';
import type { AndroidPaymentMethodDataInterface } from '../../@standard/android/mapping/android-payment-method-data.interface';
import type { AndroidPaymentData } from '../../@standard/android/response/android-payment-data';
import type { IosPaymentMethodDataInterface } from '../../@standard/ios/mapping/ios-payment-method-data.interface';
import type { IosPKPayment } from '../../@standard/ios/response/ios-pk-payment';
import type { PaymentDetailsInit } from '../../@standard/w3c/payment-details-init';
import type { PaymentItem } from '../../@standard/w3c/payment-item';
import type { PaymentMethodData } from '../../@standard/w3c/payment-method-data';
jest.mock('../native-payments/native-payments', () => ({
NativePayments: {
canMakePayments: jest.fn(),
show: jest.fn(),
abort: jest.fn(),
},
}));
jest.mock('react-native', () => ({
Platform: {
OS: 'android',
},
}));
// eslint-disable-next-line max-lines-per-function
describe('PaymentRequest', () => {
const paymentDetails = {
total: {
label: 'Total',
amount: { currency: 'USD', value: '10.00' },
},
};
// eslint-disable-next-line jest/no-hooks
beforeEach(() => {
jest.clearAllMocks();
});
// eslint-disable-next-line max-lines-per-function
describe('validation', () => {
const methodData: AndroidPaymentMethodDataInterface = {
supportedMethods: PaymentMethodNameEnum.AndroidPay,
data: {
currencyCode: 'USD',
countryCode: 'US',
supportedNetworks: [SupportedNetworkEnum.Visa, SupportedNetworkEnum.Mastercard],
environment: EnvironmentEnum.TEST,
gatewayConfig: {
gateway: 'exampleGateway',
gatewayMerchantId: 'exampleMerchantId',
},
},
};
it('should throw when payment methods not passed', () => {
expect.assertions(2);
expect(() => new PaymentRequest([], {} as unknown as PaymentDetailsInit)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': At least one payment method is required`)
);
expect(() => new PaymentRequest(undefined as unknown as PaymentMethodData[], paymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': At least one payment method is required`)
);
});
it('should throw when payment methods supportedMethods not passed', () => {
expect.assertions(2);
expect(
() => new PaymentRequest([{ supportedMethods: undefined } as unknown as PaymentMethodData], paymentDetails)
).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': required member supportedMethods is undefined.`)
);
expect(() => new PaymentRequest([{} as unknown as PaymentMethodData], paymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': required member supportedMethods is undefined.`)
);
});
describe(`payment details total`, () => {
it('should throw when total is not defined', () => {
expect.assertions(1);
const invalidPaymentDetails = {} as unknown as PaymentDetailsInit;
expect(() => new PaymentRequest([methodData], invalidPaymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': required member total is undefined.`)
);
});
it('should throw when total.amount is not defined', () => {
expect.assertions(1);
const invalidPaymentDetails = {
total: {
label: 'Total',
},
} as unknown as PaymentDetailsInit;
expect(() => new PaymentRequest([methodData], invalidPaymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': Missing required member(s): amount, label.`)
);
});
it('should throw when total.amount.value is not defined', () => {
expect.assertions(1);
const invalidPaymentDetails = {
total: {
label: 'Total',
amount: {},
},
} as unknown as PaymentDetailsInit;
expect(() => new PaymentRequest([methodData], invalidPaymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': Missing required member(s): amount, label.`)
);
});
it('should throw when total.amount.value is not monetary', () => {
expect.assertions(1);
const invalidPaymentDetails = {
total: {
label: 'Total',
amount: {
currency: 'USD',
value: true,
},
},
} as unknown as PaymentDetailsInit;
expect(() => new PaymentRequest([methodData], invalidPaymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': 'true' is not a valid amount format for total`)
);
});
it('should throw when total.amount.value is negative', () => {
expect.assertions(1);
const invalidPaymentDetails = {
total: {
label: 'Total',
amount: {
currency: 'USD',
value: '-10.00',
},
},
};
expect(() => new PaymentRequest([methodData], invalidPaymentDetails)).toThrow(
new PaymentsError(`Failed to construct 'PaymentRequest': Total amount value should be non-negative`)
);
});
it('should throw when total.amount.value ends with dot', () => {
expect.assertions(1);
const invalidPaymentDetails = {
total: {
label: 'Total',
amount: {
currency: 'USD',
value: '10.00.',
},
},
};
expect(() => new PaymentRequest([methodData], invalidPaymentDetails)).toThrow(
new PaymentsError(
`Failed to construct 'PaymentRequest': '10.00.' is not a valid amount format for total`
)
);
});
});
describe(`payment details displayItems`, () => {
const paymentDetailsWithTotal: PaymentDetailsInit = {
total: {
label: 'Total',
amount: {
currency: 'USD',
value: '10.00',
},
},
};
it('should NOT throw when displayItems is not defined or empty', () => {
expect.assertions(2);
expect(() => new PaymentRequest([methodData], paymentDetailsWithTotal)).not.toThrow();
expect(
() => new PaymentRequest([methodData], { ...paymentDetailsWithTotal, displayItems: [] })
).not.toThrow();
});
it('should throw when displayItems item has in proper shape', () => {
expect.assertions(3);
expect(
() =>
new PaymentRequest([methodData], {
...paymentDetailsWithTotal,
displayItems: [undefined as unknown as PaymentItem],
})
).toThrow(new ConstructorError(`required member value is undefined.`));
expect(
() =>
new PaymentRequest([methodData], {
...paymentDetailsWithTotal,
displayItems: [{} as unknown as PaymentItem],
})
).toThrow(new ConstructorError(`required member value is undefined.`));
expect(
() =>
new PaymentRequest([methodData], {
...paymentDetailsWithTotal,
displayItems: [{ amount: {} } as unknown as PaymentItem],
})
).toThrow(new ConstructorError(`required member value is undefined.`));
});
it('should throw when displayItems item.amount.value is not monetary', () => {
expect.assertions(1);
expect(
() =>
new PaymentRequest([methodData], {
...paymentDetailsWithTotal,
displayItems: [{ amount: { currency: 'USD', value: true } } as unknown as PaymentItem],
})
).toThrow(new ConstructorError(`'true' is not a valid amount format for display items`));
});
});
});
// eslint-disable-next-line max-lines-per-function,max-statements
describe('PaymentRequest on Android', () => {
const androidMethodData: AndroidPaymentMethodDataInterface = {
supportedMethods: PaymentMethodNameEnum.AndroidPay,
data: {
currencyCode: 'USD',
countryCode: 'US',
supportedNetworks: [SupportedNetworkEnum.Visa, SupportedNetworkEnum.Mastercard],
environment: EnvironmentEnum.TEST,
gatewayConfig: {
gateway: 'exampleGateway',
gatewayMerchantId: 'exampleMerchantId',
},
},
};
// eslint-disable-next-line jest/no-hooks
beforeEach(() => {
Platform.OS = 'android';
});
it('should initialize with the correct id', () => {
expect.assertions(2);
const request = new PaymentRequest([androidMethodData], paymentDetails);
expect(request.id).toBeDefined();
expect(request.state).toBe('created');
});
it('should throw when `canMakePayment` is called in invalid state', async () => {
expect.assertions(1);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'closed';
await expect(request.canMakePayment()).rejects.toThrow(new DOMException(PaymentsErrorEnum.InvalidStateError));
});
it('should throw when NativePayments.show rejects', async () => {
expect.assertions(2);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'created';
jest.mocked(NativePayments.show).mockRejectedValue(new DOMException(PaymentsErrorEnum.NotAllowedError));
await expect(request.show()).rejects.toThrow(new DOMException(PaymentsErrorEnum.NotAllowedError));
expect(NativePayments.show).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
});
it('should return true from `canMakePayment` when valid', async () => {
expect.assertions(2);
const request = new PaymentRequest([androidMethodData], paymentDetails);
jest.mocked(NativePayments.canMakePayments).mockResolvedValue(true);
const result = await request.canMakePayment();
expect(NativePayments.canMakePayments).toHaveBeenCalledWith(expect.any(String));
expect(result).toBe(true);
});
it('should throw when `show` is called in invalid state', async () => {
expect.assertions(1);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'closed';
await expect(request.show()).rejects.toThrow(new DOMException(PaymentsErrorEnum.InvalidStateError));
});
it(`should handle 'examplePaymentMethodToken' tokenization type`, async () => {
expect.assertions(3);
jest.mocked(NativePayments.show).mockResolvedValue(
JSON.stringify({
apiVersion: 2,
apiVersionMinor: 0,
email: 'test@example.com',
paymentMethodData: {
info: {},
tokenizationData: {
type: 'PAYMENT_GATEWAY',
token: 'examplePaymentMethodToken',
},
},
})
);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'created';
const result = await request.show();
expect(NativePayments.show).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
expect(result).toBeDefined();
expect(request.state).toBe('interactive');
});
it('should throw when `NativePayments.show` returns invalid data', async () => {
expect.assertions(1);
jest.mocked(NativePayments.show).mockResolvedValue(`...`);
const expectedError = new PaymentsError(`Failed parsing PaymentRequest details`);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'created';
await expect(request.show()).rejects.toThrow(expectedError);
});
it('should call NativePayments.show and resolve correctly', async () => {
expect.assertions(3);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'created';
jest.mocked(NativePayments.show).mockResolvedValue(
JSON.stringify({
apiVersion: 2,
apiVersionMinor: 0,
email: 'test@example.com',
paymentMethodData: {
info: {
billingAddress: {
countryCode: 'US',
name: 'John Doe',
phoneNumber: '+1234567890',
postalCode: '12345',
address1: '123 Main St',
address2: 'Suite 1',
address3: 'Building B',
administrativeArea: 'CA',
locality: 'Mountain View',
sortingCode: '123',
},
cardDetails: '1234',
cardNetwork: 'VISA',
assuranceDetails: {
accountVerified: true,
cardHolderAuthenticated: true,
},
},
tokenizationData: {
type: 'PAYMENT_GATEWAY',
token: JSON.stringify({
protocolVersion: 'ECv2',
signature: 'testSignature',
signedMessage: JSON.stringify({
encryptedMessage: 'testEncryptedMessage',
ephemeralPublicKey: 'testEphemeralPublicKey',
tag: 'testTag',
}),
intermediateSigningKey: {
signatures: ['testSignature'],
signedKey: JSON.stringify({
keyExpiration: '2024-01-01T00:00:00.000Z',
keyValue: 'testKeyValue',
}),
},
}),
},
},
shippingAddress: {
countryCode: 'US',
name: 'Jane Doe',
phoneNumber: '+9876543210',
postalCode: '54321',
address1: '456 Elm St',
address2: 'Apt 2',
address3: '',
administrativeArea: 'NY',
locality: 'New York',
sortingCode: '',
},
} as AndroidPaymentData)
);
const result = await request.show();
expect(NativePayments.show).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
expect(result).toBeDefined();
expect(request.state).toBe('interactive');
});
it('should throw when `abort` is called in invalid state', async () => {
expect.assertions(1);
const request = new PaymentRequest([androidMethodData], paymentDetails);
await expect(request.abort()).rejects.toThrow(new DOMException(PaymentsErrorEnum.InvalidStateError));
});
it('should throw when `NativePayments.abort` rejects', async () => {
expect.assertions(1);
const expectedError = new PaymentsError(`Failed aborting PaymentRequest`);
jest.mocked(NativePayments.abort).mockRejectedValue(expectedError);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'interactive';
await expect(request.abort()).rejects.toThrow(expectedError);
});
it('should call NativePayments.abort and reject correctly when aborted', async () => {
expect.assertions(2);
const request = new PaymentRequest([androidMethodData], paymentDetails);
request.state = 'interactive';
jest.mocked(NativePayments.abort).mockResolvedValue(undefined);
await request.abort();
expect(NativePayments.abort).toHaveBeenCalledWith();
expect(request.state).toBe('closed');
});
it('should throw NotSupportedError if platform payment method is not found', () => {
expect.assertions(1);
const invalidMethodData = [
{
supportedMethods: 'unsupported-method',
data: {},
},
] as unknown as PaymentMethodData[];
expect(() => new PaymentRequest(invalidMethodData, paymentDetails)).toThrow(
new DOMException(PaymentsErrorEnum.NotSupportedError)
);
});
});
// eslint-disable-next-line max-lines-per-function,max-statements
describe('PaymentRequest on iOS', () => {
const iosMethodData: IosPaymentMethodDataInterface = {
supportedMethods: PaymentMethodNameEnum.ApplePay,
data: {
requestBillingAddress: true,
requestPayerEmail: true,
requestPayerName: true,
requestPayerPhone: true,
requestShipping: true,
currencyCode: 'USD',
countryCode: 'US',
merchantIdentifier: 'merchant.com.example',
supportedNetworks: [SupportedNetworkEnum.Visa, SupportedNetworkEnum.Mastercard],
},
};
// eslint-disable-next-line jest/no-hooks
beforeEach(() => {
Platform.OS = 'ios';
});
it('should initialize with the correct id', () => {
expect.assertions(2);
const request = new PaymentRequest([iosMethodData], paymentDetails);
expect(request.id).toBeDefined();
expect(request.state).toBe('created');
});
it('should throw when `canMakePayment` is called in invalid state', async () => {
expect.assertions(1);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'closed';
await expect(request.canMakePayment()).rejects.toThrow(new DOMException(PaymentsErrorEnum.InvalidStateError));
});
it('should return true from `canMakePayment` when valid', async () => {
expect.assertions(2);
const request = new PaymentRequest([iosMethodData], paymentDetails);
jest.mocked(NativePayments.canMakePayments).mockResolvedValue(true);
const result = await request.canMakePayment();
expect(NativePayments.canMakePayments).toHaveBeenCalledWith(expect.any(String));
expect(result).toBe(true);
});
it('should throw when `show` is called in invalid state', async () => {
expect.assertions(1);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'closed';
await expect(request.show()).rejects.toThrow(new DOMException(PaymentsErrorEnum.InvalidStateError));
});
it('should throw when NativePayments.show rejects', async () => {
expect.assertions(2);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'created';
jest.mocked(NativePayments.show).mockRejectedValue(new DOMException(PaymentsErrorEnum.NotAllowedError));
await expect(request.show()).rejects.toThrow(new DOMException(PaymentsErrorEnum.NotAllowedError));
expect(NativePayments.show).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
});
it('should throw when `NativePayments.show` returns invalid data', async () => {
expect.assertions(1);
jest.mocked(NativePayments.show).mockResolvedValue(`...`);
const expectedError = new PaymentsError(`Failed parsing PaymentRequest details`);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'created';
await expect(request.show()).rejects.toThrow(expectedError);
});
it('should call NativePayments.show and resolve correctly', async () => {
expect.assertions(3);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'created';
jest.mocked(NativePayments.show).mockResolvedValue(
JSON.stringify({
billingContact: {
postalAddress: {
street: '1 Infinite Loop',
city: 'Cupertino',
state: 'CA',
postalCode: '95014',
country: 'USA',
ISOCountryCode: 'US',
subAdministrativeArea: '',
subLocality: '',
},
},
shippingContact: {
name: {
givenName: 'John',
familyName: 'Doe',
middleName: '',
namePrefix: '',
nameSuffix: '',
nickname: '',
},
emailAddress: 'johndoe@example.com',
phoneNumber: { stringValue: '+1-555-555-5555' },
postalAddress: {
street: '1 Infinite Loop',
city: 'Cupertino',
state: 'CA',
postalCode: '95014',
country: 'USA',
ISOCountryCode: 'US',
subAdministrativeArea: '',
subLocality: '',
},
},
shippingMethod: {
identifier: 'standard',
detail: 'Standard Shipping (3-5 business days)',
},
token: {
paymentData: JSON.stringify({
version: 'EC_v1',
data: 'enHx9XCGOPE...',
signature: 'abcd1234...',
header: {
ephemeralPublicKey: 'AbCdEf...',
publicKeyHash: 'gHiJkL...',
transactionId: 'txn01',
},
}),
paymentMethod: {
displayName: 'Visa',
network: 'Visa',
type: IosPKPaymentMethodType.PKPaymentMethodTypeDebit,
},
transactionIdentifier: 'txn123456789',
},
} as IosPKPayment)
);
const result = await request.show();
expect(NativePayments.show).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
expect(result).toBeDefined();
expect(request.state).toBe('interactive');
});
it('should throw when `abort` is called in invalid state', async () => {
expect.assertions(1);
const request = new PaymentRequest([iosMethodData], paymentDetails);
await expect(request.abort()).rejects.toThrow(new DOMException(PaymentsErrorEnum.InvalidStateError));
});
it('should throw when `NativePayments.abort` rejects', async () => {
expect.assertions(1);
const expectedError = new PaymentsError(`Failed aborting PaymentRequest`);
jest.mocked(NativePayments.abort).mockRejectedValue(expectedError);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'interactive';
await expect(request.abort()).rejects.toThrow(expectedError);
});
it('should call NativePayments.abort and reject correctly when aborted', async () => {
expect.assertions(2);
const request = new PaymentRequest([iosMethodData], paymentDetails);
request.state = 'interactive';
jest.mocked(NativePayments.abort).mockResolvedValue(undefined);
await request.abort();
expect(NativePayments.abort).toHaveBeenCalledWith();
expect(request.state).toBe('closed');
});
it('should throw NotSupportedError if platform payment method is not found', () => {
expect.assertions(1);
const invalidMethodData = [
{
supportedMethods: 'unsupported-method',
data: {},
},
] as unknown as PaymentMethodData[];
expect(() => new PaymentRequest(invalidMethodData, paymentDetails)).toThrow(
new DOMException(PaymentsErrorEnum.NotSupportedError)
);
});
});
});