Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
9 changes: 9 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
testMatch: [
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
};
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"scripts": {
"build": "tsup src/index.tsx --format cjs,esm --dts",
"lint": "tsc",
"test": "jest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build -o docs-build"
},
Expand Down Expand Up @@ -53,11 +54,17 @@
"@storybook/react-webpack5": "^7.5.3",
"@storybook/testing-library": "^0.2.2",
"@svgr/cli": "^8.1.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/react": "^18.2.38",
"jest": "^30.1.3",
"jest-environment-jsdom": "^30.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sb": "^7.5.3",
"storybook": "^7.5.3",
"ts-jest": "^29.4.1",
"tsup": "^8.0.1",
"typescript": "^5.3.2"
},
Expand Down
2,315 changes: 2,297 additions & 18 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/icons/flat/components/Amex.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import SvgAmex from './Amex';

describe('Amex', () => {
it('should render the Amex icon with the correct blue background', () => {
const { container } = render(<SvgAmex />);
const pathElement = container.querySelector('path[fill="#2557D6"]');
expect(pathElement).toBeInTheDocument();

Check failure on line 9 in src/icons/flat/components/Amex.test.tsx

View workflow job for this annotation

GitHub Actions / build

Property 'toBeInTheDocument' does not exist on type 'JestMatchers<Element | null>'.
});
});
121 changes: 121 additions & 0 deletions src/utils/cardUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
sanitizeCardNumber,
detectCardType,
validateCardNumber,
formatCardNumber,
validateCardForType,
getCardLengthRange,
isCardNumberPotentiallyValid,
maskCardNumber,
} from './cardUtils';

describe('cardUtils', () => {
describe('sanitizeCardNumber', () => {
it('should remove non-digit characters', () => {
expect(sanitizeCardNumber('1234-5678-9012-3456')).toBe('1234567890123456');
expect(sanitizeCardNumber('1234 5678 9012 3456')).toBe('1234567890123456');
expect(sanitizeCardNumber('1234abcd5678')).toBe('12345678');
});
});

describe('detectCardType', () => {
const testCases = [
{ cardNumber: '4111111111111111', expected: 'Visa' },
{ cardNumber: '5111111111111111', expected: 'Mastercard' },
{ cardNumber: '2221111111111111', expected: 'Mastercard' },
{ cardNumber: '341111111111111', expected: 'Amex' },
{ cardNumber: '371111111111111', expected: 'Amex' },
{ cardNumber: '6011111111111117', expected: 'Discover' },
{ cardNumber: '30569309025904', expected: 'Diners' },
{ cardNumber: '3528111111111111', expected: 'Jcb' },
{ cardNumber: '6221261111111111', expected: 'Discover' },
{ cardNumber: '5018111111111111', expected: 'Maestro' },
{ cardNumber: '4011781111111111', expected: 'Elo' },
{ cardNumber: '6062821111111111', expected: 'Hipercard' },
{ cardNumber: '2200111111111111', expected: 'Mir' },
{ cardNumber: '1234567890', expected: 'Generic' },
];

testCases.forEach(({ cardNumber, expected }) => {
it(`should detect ${expected} for card number ${cardNumber}`, () => {
expect(detectCardType(cardNumber)).toBe(expected);
});
});
});

describe('validateCardNumber', () => {
it('should validate a correct card number', () => {
expect(validateCardNumber('4242424242424242')).toBe(true);
});

it('should invalidate an incorrect card number', () => {
expect(validateCardNumber('4242424242424243')).toBe(false);
});
});

describe('formatCardNumber', () => {
it('should format a standard card number', () => {
expect(formatCardNumber('1234567890123456')).toBe('1234 5678 9012 3456');
});

it('should format an Amex card number', () => {
expect(formatCardNumber('378282246310005')).toBe('3782 822463 10005');
});

it('should format a Diners card number', () => {
expect(formatCardNumber('30569300090904')).toBe('3056 930009 0904');
});
});

describe('validateCardForType', () => {
it('should validate a correct card for its type', () => {
expect(validateCardForType('4242424242424242', 'Visa')).toBe(true);
});

it('should not validate an incorrect card for its type', () => {
expect(validateCardForType('5111111111111111', 'Visa')).toBe(false);
});
});

describe('getCardLengthRange', () => {
it('should return the correct length range for Amex', () => {
expect(getCardLengthRange('Amex')).toEqual({ min: 15, max: 15 });
});

it('should return null for a generic card', () => {
expect(getCardLengthRange('Generic')).toBeNull();
});
});

describe('isCardNumberPotentiallyValid', () => {
it('should return true for a potentially valid card number', () => {
expect(isCardNumberPotentiallyValid('4111111111111')).toBe(true);
});

it('should return false for a number that is too short', () => {
expect(isCardNumberPotentiallyValid('4111')).toBe(false);
});

it('should return false for a number that is too long', () => {
expect(isCardNumberPotentiallyValid('411111111111111111111')).toBe(false);
});
});

describe('maskCardNumber', () => {
it('should mask a standard card number', () => {
expect(maskCardNumber('1234567890123456')).toBe('**** **** **** 3456');
});

it('should mask an Amex card number', () => {
expect(maskCardNumber('378282246310005')).toBe('**** ****** *0005');
});

it('should mask a Diners card number', () => {
expect(maskCardNumber('30569300090904')).toBe('**** ****** 0904');
});

it('should not mask short numbers', () => {
expect(maskCardNumber('123')).toBe('123');
});
});
});
78 changes: 50 additions & 28 deletions src/utils/cardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,26 @@ const cardPatterns: Record<PaymentType, RegExp[]> = {
};

// IIN (Issuer Identification Number) ranges for faster detection
const iinPatterns: Record<PaymentType, string[]> = {
Visa: ['4'],
Mastercard: ['51', '52', '53', '54', '55', '222', '223', '224', '225', '226', '227', '228', '229', '23', '24', '25', '26', '27'],
Amex: ['34', '37'],
Discover: ['6011', '622', '64', '65'],
Diners: ['300', '301', '302', '303', '304', '305', '36', '38'],
Jcb: ['35', '2131', '1800'],
Unionpay: ['62'],
Maestro: ['50', '56', '57', '58', '6304', '6390', '67'],
Elo: ['401178', '401179', '431274', '438935', '451416', '457393', '457631', '457632', '504175', '506699', '5067', '627780', '636297', '636368', '636369'],
Hiper: ['606282', '3841'],
Hipercard: ['606282', '3841'],
Mir: ['2200', '2201', '2202', '2203', '2204'],
Paypal: [],
Alipay: [],
Generic: [],
Code: [],
CodeFront: [],
Swish: []
};
const iinPatterns: [PaymentType, string[]][] = [
['Elo', ['401178', '401179', '431274', '438935', '451416', '457393', '457631', '457632', '504175', '506699', '5067', '627780', '636297', '636368', '636369']],
['Hipercard', ['606282', '3841']],
['Hiper', ['606282', '3841']],
['Discover', ['6011', '622', '64', '65']],
['Unionpay', ['62']],
['Visa', ['4']],
['Mastercard', ['51', '52', '53', '54', '55', '222', '223', '224', '225', '226', '227', '228', '229', '23', '24', '25', '26', '27']],
['Amex', ['34', '37']],
['Diners', ['300', '301', '302', '303', '304', '305', '36', '38']],
['Jcb', ['35', '2131', '1800']],
['Maestro', ['50', '56', '57', '58', '6304', '6390', '67']],
['Mir', ['2200', '2201', '2202', '2203', '2204']],
['Paypal', []],
['Alipay', []],
['Generic', []],
['Code', []],
['CodeFront', []],
['Swish', []]
];

/**
* Removes all non-digit characters from a card number
Expand All @@ -62,16 +62,22 @@ export function detectCardType(cardNumber: string): PaymentType {
return 'Generic';
}

// Check IIN patterns for faster detection
for (const cardType in iinPatterns) {
const patterns = iinPatterns[cardType as PaymentType];
let bestMatch: PaymentType | null = null;
let longestPattern = 0;

for (const [cardType, patterns] of iinPatterns) {
for (const pattern of patterns) {
if (sanitized.startsWith(pattern)) {
return cardType as PaymentType;
if (sanitized.startsWith(pattern) && pattern.length > longestPattern) {
bestMatch = cardType;
longestPattern = pattern.length;
}
}
}

if (bestMatch) {
return bestMatch;
}

// Fallback to full regex validation for edge cases
for (const cardType in cardPatterns) {
const patterns = cardPatterns[cardType as PaymentType];
Expand Down Expand Up @@ -209,8 +215,24 @@ export function maskCardNumber(cardNumber: string, maskChar: string = '*'): stri
return sanitized;
}

const lastFour = sanitized.slice(-4);
const maskedPortion = maskChar.repeat(sanitized.length - 4);
const formatted = formatCardNumber(sanitized);
const numToMask = sanitized.length - 4;
let masked = '';
let digitsSeen = 0;

for (let i = 0; i < formatted.length; i++) {
const char = formatted[i];
if (char !== ' ') {
digitsSeen++;
if (digitsSeen <= numToMask) {
masked += maskChar;
} else {
masked += char;
}
} else {
masked += ' ';
}
}

return formatCardNumber(maskedPortion + lastFour);
return masked;
}
Loading