From edc38a12301f8ba632d1a846d12714971bc9b770 Mon Sep 17 00:00:00 2001 From: Ildar Galeev Date: Fri, 1 Nov 2024 21:28:24 +0700 Subject: [PATCH] EMP-97: Add copy value formatter. Add parseNationalNumber (#360) --- jest.config.js | 2 +- .../Destinations/DestinationInfo.tsx | 5 +- .../Destinations/InfoItem.tsx | 11 ++- .../utils/__tests__/formatCardPan.test.ts | 77 +++++++++++++++++++ .../utils/__tests__/formatPhoneNumber.test.ts | 51 ++++++++++++ .../__tests__/normalizePhoneNumber.test.ts | 17 ++++ .../__tests__/parseNationalNumber.test.ts | 57 ++++++++++++++ .../Destinations/utils/formatPhoneNumber.ts | 5 +- .../Destinations/utils/index.ts | 2 + .../utils/normalizePhoneNumber.ts | 2 + .../Destinations/utils/parseNationalNumber.ts | 17 ++++ 11 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatCardPan.test.ts create mode 100644 src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatPhoneNumber.test.ts create mode 100644 src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/normalizePhoneNumber.test.ts create mode 100644 src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/parseNationalNumber.test.ts create mode 100644 src/components/ViewContainer/ApiExtensionView/Destinations/utils/normalizePhoneNumber.ts create mode 100644 src/components/ViewContainer/ApiExtensionView/Destinations/utils/parseNationalNumber.ts diff --git a/jest.config.js b/jest.config.js index 13ce4926..548064a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,6 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { - '^checkout/(.*)': '/src/app/$1', + '^checkout/(.*)$': '/src/common/$1', }, }; diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/DestinationInfo.tsx b/src/components/ViewContainer/ApiExtensionView/Destinations/DestinationInfo.tsx index 62fa7494..04d98c02 100644 --- a/src/components/ViewContainer/ApiExtensionView/Destinations/DestinationInfo.tsx +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/DestinationInfo.tsx @@ -9,7 +9,7 @@ import { DestinationBankAccountInfo } from './DestinationBankAccountInfo'; import { DestinationNotification } from './DestinationNotification'; import { DestinationQRCodeAccountInfo } from './DestinationQRCodeAccountInfo'; import { InfoItem } from './InfoItem'; -import { formatCardPan, formatPhoneNumber } from './utils'; +import { formatCardPan, formatPhoneNumber, normalizePhoneNumber, parseNationalNumber } from './utils'; import { SBPIcon } from '../icons/SBPIcon'; import { getGatewayIcon, mapGatewayName } from '../utils'; @@ -58,11 +58,12 @@ export function DestinationInfo({ destination }: DestinationInfoProps) { {destination.destinationType === 'BankAccount' && } {(destination.destinationType === 'DestinationSBP' || destination.destinationType === 'SBP') && ( : null} isCopyable={true} label={l['form.p2p.destination.spb.phone']} - value={destination.phoneNumber} + value={normalizePhoneNumber(destination.phoneNumber)} /> )} {destination?.bankName && ( diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/InfoItem.tsx b/src/components/ViewContainer/ApiExtensionView/Destinations/InfoItem.tsx index 9805d675..93ad78fa 100644 --- a/src/components/ViewContainer/ApiExtensionView/Destinations/InfoItem.tsx +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/InfoItem.tsx @@ -12,11 +12,13 @@ export type InfoItemProps = { isCopyable?: boolean; isDivider?: boolean; formatter?: (value: string) => Promise; + copyValueFormatter?: (value: string) => Promise; }; -export function InfoItem({ label, value, isCopyable, formatter, icon, isDivider }: InfoItemProps) { +export function InfoItem({ label, value, isCopyable, formatter, icon, isDivider, copyValueFormatter }: InfoItemProps) { const { l } = useContext(LocaleContext); - const { onCopy, hasCopied } = useClipboard(value); + const [copiedValue, setCopiedValue] = useState(value); + const { onCopy, hasCopied } = useClipboard(copiedValue); const [displayValue, setDisplayValue] = useState(value); const toast = useToast(); @@ -32,6 +34,11 @@ export function InfoItem({ label, value, isCopyable, formatter, icon, isDivider formatter(value).then(setDisplayValue); }, [value, formatter]); + useEffect(() => { + if (!copyValueFormatter) return; + copyValueFormatter(value).then(setCopiedValue); + }, [value, copyValueFormatter]); + useEffect(() => { setDisplayValue(value); }, [value]); diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatCardPan.test.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatCardPan.test.ts new file mode 100644 index 00000000..a5d2e9bd --- /dev/null +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatCardPan.test.ts @@ -0,0 +1,77 @@ +import { formatCard } from 'checkout/utils'; + +import { formatCardPan } from '../formatCardPan'; + +// Mock the dependencies +jest.mock('checkout/utils', () => ({ + extractError: jest.fn((error) => error.message), + formatCard: jest.fn(), +})); + +describe('formatCardPan', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + (formatCard as jest.Mock).mockReset(); + // Setup console.error spy and silence it + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.resetModules(); + consoleErrorSpy.mockRestore(); + }); + + it('should format card pan successfully', async () => { + const pan = '4111111111111111'; + const mockValidationResult = { + card: { + niceType: 'Visa', + type: 'visa', + patterns: [4], + gaps: [4, 8, 12], + lengths: [16, 18, 19], + code: { + name: 'CVV', + size: 3, + }, + matchStrength: 1, + }, + isValid: true, + isPotentiallyValid: true, + }; + + // Mock successful dynamic import + jest.mock('card-validator', () => ({ + number: jest.fn().mockReturnValue(mockValidationResult), + })); + + (formatCard as jest.Mock).mockReturnValue('4111 1111 1111 1111'); + + const result = await formatCardPan(pan); + + expect(result).toBe('4111 1111 1111 1111'); + expect(formatCard).toHaveBeenCalledWith(pan, mockValidationResult); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should return original pan when card-validator fails to load', async () => { + const pan = '4111111111111111'; + + // Force the dynamic import to fail + jest.mock('card-validator', () => { + throw new Error('Failed to load card-validator'); + }); + + // Reset formatCard mock to ensure it's not returning a value from previous test + (formatCard as jest.Mock).mockImplementation(() => pan); + + const result = await formatCardPan(pan); + + expect(result).toBe(pan); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error loading the card-validator library Failed to load card-validator', + ); + }); +}); diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatPhoneNumber.test.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatPhoneNumber.test.ts new file mode 100644 index 00000000..f555a0de --- /dev/null +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/formatPhoneNumber.test.ts @@ -0,0 +1,51 @@ +import { formatPhoneNumber } from '../formatPhoneNumber'; + +let mockThrowError = false; + +jest.mock('libphonenumber-js/min', () => ({ + AsYouType: jest.fn().mockImplementation(() => { + if (mockThrowError) { + throw new Error('Failed to load library'); + } + return { + input: jest.fn().mockImplementation((number) => { + if (number === '1234567890') { + return '(123) 456-7890'; + } + return number; + }), + }; + }), +})); + +describe('formatPhoneNumber', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockThrowError = false; + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should format a valid phone number', async () => { + const result = await formatPhoneNumber('1234567890'); + expect(result).toBe('(123) 456-7890'); + }); + + it('should return original number when formatting fails', async () => { + mockThrowError = true; + const originalNumber = '1234567890'; + const result = await formatPhoneNumber(originalNumber); + expect(result).toBe(originalNumber); + expect(consoleErrorSpy).toHaveBeenCalledWith('Format phone number failed: Error: Failed to load library'); + }); + + it('should handle empty string', async () => { + const result = await formatPhoneNumber(''); + expect(result).toBe(''); + }); +}); diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/normalizePhoneNumber.test.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/normalizePhoneNumber.test.ts new file mode 100644 index 00000000..2c675020 --- /dev/null +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/normalizePhoneNumber.test.ts @@ -0,0 +1,17 @@ +import { normalizePhoneNumber } from '../normalizePhoneNumber'; + +describe('normalizePhoneNumber', () => { + it('should return the same number when it already starts with +', () => { + expect(normalizePhoneNumber('+12345678900')).toBe('+12345678900'); + expect(normalizePhoneNumber('+44123456789')).toBe('+44123456789'); + }); + + it('should add + prefix when number does not start with +', () => { + expect(normalizePhoneNumber('12345678900')).toBe('+12345678900'); + expect(normalizePhoneNumber('44123456789')).toBe('+44123456789'); + }); + + it('should handle empty string', () => { + expect(normalizePhoneNumber('')).toBe('+'); + }); +}); diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/parseNationalNumber.test.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/parseNationalNumber.test.ts new file mode 100644 index 00000000..1bf4384d --- /dev/null +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/__tests__/parseNationalNumber.test.ts @@ -0,0 +1,57 @@ +import { parseNationalNumber } from '../parseNationalNumber'; + +// Mock the libphonenumber-js import +jest.mock('libphonenumber-js/min', () => ({ + parsePhoneNumber: jest.fn((number) => { + if (number === '+994501234567') { + return { + country: 'AZ', + nationalNumber: '501234567', + }; + } + if (number === '+79123456789') { + return { + country: 'RU', + nationalNumber: '9123456789', + }; + } + throw new Error('Invalid phone number'); + }), +})); + +describe('parseNationalNumber', () => { + beforeEach(() => { + jest.clearAllMocks(); + console.error = jest.fn(); // Mock console.error + }); + + it('should return national number for matching country', async () => { + const parser = parseNationalNumber(['AZ']); + const result = await parser('+994501234567'); + expect(result).toBe('501234567'); + }); + + it('should return original number for non-matching country', async () => { + const parser = parseNationalNumber(['RU']); + const result = await parser('+994501234567'); + expect(result).toBe('+994501234567'); + }); + + it('should handle multiple countries', async () => { + const parser = parseNationalNumber(['RU', 'AZ']); + const result1 = await parser('+994501234567'); + const result2 = await parser('+79123456789'); + + expect(result1).toBe('501234567'); + expect(result2).toBe('9123456789'); + }); + + it('should return original number on parsing error', async () => { + const parser = parseNationalNumber(['AZ']); + const invalidNumber = 'invalid'; + const result = await parser(invalidNumber); + + expect(result).toBe(invalidNumber); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/formatPhoneNumber.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/formatPhoneNumber.ts index ac326d97..06e1b3da 100644 --- a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/formatPhoneNumber.ts +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/formatPhoneNumber.ts @@ -3,9 +3,10 @@ import { extractError } from 'checkout/utils'; export const formatPhoneNumber = async (phoneNumber: string): Promise => { try { const lib = await import('libphonenumber-js/min'); - return new lib.AsYouType().input(phoneNumber); + const instance = new lib.AsYouType(); + return instance.input(phoneNumber); } catch (error) { - console.error(`Error loading the libphonenumber-js library ${extractError(error)}`); + console.error(`Format phone number failed: ${extractError(error)}`); return phoneNumber; } }; diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/index.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/index.ts index a81dda98..08b9d4bf 100644 --- a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/index.ts +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/index.ts @@ -1,2 +1,4 @@ export { formatCardPan } from './formatCardPan'; export { formatPhoneNumber } from './formatPhoneNumber'; +export { normalizePhoneNumber } from './normalizePhoneNumber'; +export { parseNationalNumber } from './parseNationalNumber'; diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/normalizePhoneNumber.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/normalizePhoneNumber.ts new file mode 100644 index 00000000..31549235 --- /dev/null +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/normalizePhoneNumber.ts @@ -0,0 +1,2 @@ +export const normalizePhoneNumber = (phoneNumber: string): string => + phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`; diff --git a/src/components/ViewContainer/ApiExtensionView/Destinations/utils/parseNationalNumber.ts b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/parseNationalNumber.ts new file mode 100644 index 00000000..1c1d9b7b --- /dev/null +++ b/src/components/ViewContainer/ApiExtensionView/Destinations/utils/parseNationalNumber.ts @@ -0,0 +1,17 @@ +import { extractError } from 'checkout/utils'; + +export const parseNationalNumber = + (forCountries: string[]) => + async (phoneNumber: string): Promise => { + try { + const lib = await import('libphonenumber-js/min'); + const parsed = lib.parsePhoneNumber(phoneNumber); + if (forCountries.includes(parsed.country)) { + return parsed.nationalNumber; + } + return phoneNumber; + } catch (error) { + console.error(`Parse national number failed: ${extractError(error)}`); + return phoneNumber; + } + };