EMP-97: Add copy value formatter. Add parseNationalNumber (#360)
Some checks are pending
Master / Build (push) Waiting to run

This commit is contained in:
Ildar Galeev 2024-11-01 21:28:24 +07:00 committed by GitHub
parent 088f181c6e
commit edc38a1230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 239 additions and 7 deletions

View File

@ -3,6 +3,6 @@ module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom', testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: { moduleNameMapper: {
'^checkout/(.*)': '<rootDir>/src/app/$1', '^checkout/(.*)$': '<rootDir>/src/common/$1',
}, },
}; };

View File

@ -9,7 +9,7 @@ import { DestinationBankAccountInfo } from './DestinationBankAccountInfo';
import { DestinationNotification } from './DestinationNotification'; import { DestinationNotification } from './DestinationNotification';
import { DestinationQRCodeAccountInfo } from './DestinationQRCodeAccountInfo'; import { DestinationQRCodeAccountInfo } from './DestinationQRCodeAccountInfo';
import { InfoItem } from './InfoItem'; import { InfoItem } from './InfoItem';
import { formatCardPan, formatPhoneNumber } from './utils'; import { formatCardPan, formatPhoneNumber, normalizePhoneNumber, parseNationalNumber } from './utils';
import { SBPIcon } from '../icons/SBPIcon'; import { SBPIcon } from '../icons/SBPIcon';
import { getGatewayIcon, mapGatewayName } from '../utils'; import { getGatewayIcon, mapGatewayName } from '../utils';
@ -58,11 +58,12 @@ export function DestinationInfo({ destination }: DestinationInfoProps) {
{destination.destinationType === 'BankAccount' && <DestinationBankAccountInfo destination={destination} />} {destination.destinationType === 'BankAccount' && <DestinationBankAccountInfo destination={destination} />}
{(destination.destinationType === 'DestinationSBP' || destination.destinationType === 'SBP') && ( {(destination.destinationType === 'DestinationSBP' || destination.destinationType === 'SBP') && (
<InfoItem <InfoItem
copyValueFormatter={parseNationalNumber(['AZ'])}
formatter={formatPhoneNumber} formatter={formatPhoneNumber}
icon={currency === 'RUB' ? <SBPIcon /> : null} icon={currency === 'RUB' ? <SBPIcon /> : null}
isCopyable={true} isCopyable={true}
label={l['form.p2p.destination.spb.phone']} label={l['form.p2p.destination.spb.phone']}
value={destination.phoneNumber} value={normalizePhoneNumber(destination.phoneNumber)}
/> />
)} )}
{destination?.bankName && ( {destination?.bankName && (

View File

@ -12,11 +12,13 @@ export type InfoItemProps = {
isCopyable?: boolean; isCopyable?: boolean;
isDivider?: boolean; isDivider?: boolean;
formatter?: (value: string) => Promise<string>; formatter?: (value: string) => Promise<string>;
copyValueFormatter?: (value: string) => Promise<string>;
}; };
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 { l } = useContext(LocaleContext);
const { onCopy, hasCopied } = useClipboard(value); const [copiedValue, setCopiedValue] = useState(value);
const { onCopy, hasCopied } = useClipboard(copiedValue);
const [displayValue, setDisplayValue] = useState(value); const [displayValue, setDisplayValue] = useState(value);
const toast = useToast(); const toast = useToast();
@ -32,6 +34,11 @@ export function InfoItem({ label, value, isCopyable, formatter, icon, isDivider
formatter(value).then(setDisplayValue); formatter(value).then(setDisplayValue);
}, [value, formatter]); }, [value, formatter]);
useEffect(() => {
if (!copyValueFormatter) return;
copyValueFormatter(value).then(setCopiedValue);
}, [value, copyValueFormatter]);
useEffect(() => { useEffect(() => {
setDisplayValue(value); setDisplayValue(value);
}, [value]); }, [value]);

View File

@ -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',
);
});
});

View File

@ -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('');
});
});

View File

@ -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('+');
});
});

View File

@ -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();
});
});

View File

@ -3,9 +3,10 @@ import { extractError } from 'checkout/utils';
export const formatPhoneNumber = async (phoneNumber: string): Promise<string> => { export const formatPhoneNumber = async (phoneNumber: string): Promise<string> => {
try { try {
const lib = await import('libphonenumber-js/min'); const lib = await import('libphonenumber-js/min');
return new lib.AsYouType().input(phoneNumber); const instance = new lib.AsYouType();
return instance.input(phoneNumber);
} catch (error) { } catch (error) {
console.error(`Error loading the libphonenumber-js library ${extractError(error)}`); console.error(`Format phone number failed: ${extractError(error)}`);
return phoneNumber; return phoneNumber;
} }
}; };

View File

@ -1,2 +1,4 @@
export { formatCardPan } from './formatCardPan'; export { formatCardPan } from './formatCardPan';
export { formatPhoneNumber } from './formatPhoneNumber'; export { formatPhoneNumber } from './formatPhoneNumber';
export { normalizePhoneNumber } from './normalizePhoneNumber';
export { parseNationalNumber } from './parseNationalNumber';

View File

@ -0,0 +1,2 @@
export const normalizePhoneNumber = (phoneNumber: string): string =>
phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`;

View File

@ -0,0 +1,17 @@
import { extractError } from 'checkout/utils';
export const parseNationalNumber =
(forCountries: string[]) =>
async (phoneNumber: string): Promise<string> => {
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;
}
};