diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index bdc7428..f41d398 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,57 +1,45 @@ -name: CD - -on: - push: - branches: - - master - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - # Check out as an admin to allow for pushing back to master - token: ${{ secrets.GH_OAUTH_TOKEN }} - # We need to fetch all tags and branches - fetch-depth: 0 - - - name: Use Node.js environment - uses: actions/setup-node@v2 - with: - node-version: '14.x' - registry-url: https://registry.npmjs.org/ - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn --frozen-lockfile - - - name: Build all - run: yarn build - - - name: Creates release if necessary - if: github.ref == 'refs/heads/master' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - npm whoami - - OLD_PACKAGE_VERSION=$(echo $(git show HEAD~1:package.json) | jq '.version') - NEW_PACKAGE_VERSION=$(echo $(git show HEAD:package.json) | jq '.version') - - if [ "$NEW_PACKAGE_VERSION" != "$OLD_PACKAGE_VERSION" ]; then - printf "Changed version [%s]\n" "$NEW_PACKAGE_VERSION" - - npm publish --access public - fi +name: CD + +on: + push: + branches: + - master + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Check out as an admin to allow for pushing back to master + token: ${{ secrets.GH_OAUTH_TOKEN }} + # We need to fetch all tags and branches + fetch-depth: 0 + + - name: Use Node.js environment + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: yarn --frozen-lockfile + + - name: Build all + run: yarn build + + - name: Creates release if necessary + if: github.ref == 'refs/heads/master' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + npm whoami + + OLD_PACKAGE_VERSION=$(echo $(git show HEAD~1:package.json) | jq '.version') + NEW_PACKAGE_VERSION=$(echo $(git show HEAD:package.json) | jq '.version') + + if [ "$NEW_PACKAGE_VERSION" != "$OLD_PACKAGE_VERSION" ]; then + printf "Changed version [%s]\n" "$NEW_PACKAGE_VERSION" + + npm publish --access public + fi diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 847fdf0..55a1df5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,27 +6,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: # we actually need "github.event.pull_request.commits + 1" commit fetch-depth: 0 - name: Use Node.js environment - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: '14.x' - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + node-version: '20.x' - name: Install dependencies run: yarn install @@ -36,4 +24,3 @@ jobs: - name: Run tests run: yarn test --coverage - diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 5a64538..9ed0647 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -14,15 +14,15 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v4 with: - node-version: "16" + node-version: "20" - run: yarn install - name: Run eslint with reviewdog - uses: reviewdog/action-eslint@v1.16.1 + uses: reviewdog/action-eslint@v1.33.0 with: eslint_flags: . --ext .js,.jsx,.ts,.tsx diff --git a/package.json b/package.json index 86dde1b..4f57dfd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ }, "dependencies": { "@sentry/react": "^8.9.2", - "react": "^18.2.0" + "cpf-cnpj-validator": "2.1.0", + "react": "^18.2.0", + "vanilla-masker": "^1.2.0" }, "devDependencies": { "@types/jest": "^29.2.3", @@ -27,6 +29,7 @@ "eslint-plugin-react": "^7.31.10", "eslint-plugin-react-hooks": "^4.6.0", "jest": "^29.2.2", + "jest-environment-jsdom": "29", "prettier": "^2.3.2", "ts-jest": "^29.0.3", "ts-node-dev": "^2.0.0", diff --git a/src/formatters/formatCnpj.ts b/src/formatters/formatCnpj.ts index f519b57..640a308 100644 --- a/src/formatters/formatCnpj.ts +++ b/src/formatters/formatCnpj.ts @@ -1,11 +1,13 @@ +import VMasker from 'vanilla-masker'; +import { MASKS } from '../masks/masks'; +import { stripAlphanumeric } from '../masks/strip'; + const formatCnpj = (value: string): string => { - const formattedValue = value.replace(/\D/g, '').slice(0, 14); + if (!value) return ''; + + const stripped = stripAlphanumeric(value).slice(0, 14); - return formattedValue - .replace(/^(\d{2})(\d)/, '$1.$2') - .replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3') - .replace(/^(\d{2})\.(\d{3})\.(\d{3})(\d)/, '$1.$2.$3/$4') - .replace(/^(\d{2})\.(\d{3})\.(\d{3})\/(\d{4})(\d)/, '$1.$2.$3/$4-$5'); + return VMasker.toPattern(stripped, MASKS.CNPJ); }; export default formatCnpj; diff --git a/src/index.ts b/src/index.ts index 8a9e856..ffc6707 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,16 @@ export { default as isCpf } from './validations/isCpf'; export { default as isCnpj } from './validations/isCnpj'; export { default as isPis } from './validations/isPis'; +// masks +export { default as maskValue } from './masks/maskValue'; +export { default as maskInput } from './masks/maskInput'; +export { default as maskCpf } from './masks/maskCpf'; +export { default as maskCpfOrCnpj } from './masks/maskCpfOrCnpj'; +export { default as maskComplete } from './masks/maskComplete'; +export { default as setupMultipleMask } from './masks/setupMultipleMask'; +export { default as maskHintBankAccount } from './masks/maskHintBankAccount'; +export type { MaskType, CurrencyMaskOptions, BankCompensationCode } from './masks/types'; + // breakpoints export { default as breakpointFrom } from './styles/breakpointFrom'; diff --git a/src/masks/maskComplete.ts b/src/masks/maskComplete.ts new file mode 100644 index 0000000..037d31a --- /dev/null +++ b/src/masks/maskComplete.ts @@ -0,0 +1,48 @@ +import { MASKS } from './masks'; +import { stripAlphanumeric, stripNumeric, PATTERN_PLACEHOLDER_REGEX } from './strip'; +import type { MaskType } from './types'; + +const ALPHANUMERIC_MASKS: ReadonlyArray = ['cnpj', 'cpf_cnpj']; + +const placeholdersInPattern = (pattern: string): number => { + const matches = pattern.match(PATTERN_PLACEHOLDER_REGEX); + return matches ? matches.length : 0; +}; + +const patternFor = (type: MaskType): string | null => { + switch (type) { + case 'cpf': return MASKS.CPF; + case 'cnpj': return MASKS.CNPJ; + case 'phone': return MASKS.PHONE; + case 'zipcode': return MASKS.ZIPCODE; + case 'date': return MASKS.DATE; + case 'barCode': return MASKS.BAR_CODE; + case 'barCodeUtilities': return MASKS.BAR_CODE_UTILITIES; + case 'darf': return MASKS.DARF; + case 'number': return MASKS.NUMBER; + default: return null; + } +}; + +export default function maskComplete(value: string, type: MaskType): boolean { + if (!value) return false; + + if (type === 'currency' || type === 'percentage') { + return value.length > 0; + } + + if (type === 'cpf_cnpj') { + const stripped = stripAlphanumeric(value); + return stripped.length === 11 || stripped.length === 14; + } + + const pattern = patternFor(type); + if (pattern === null) return false; + + const expected = placeholdersInPattern(pattern); + const stripped = ALPHANUMERIC_MASKS.includes(type) + ? stripAlphanumeric(value) + : stripNumeric(value); + + return stripped.length === expected; +} diff --git a/src/masks/maskCpf.ts b/src/masks/maskCpf.ts new file mode 100644 index 0000000..63da8b7 --- /dev/null +++ b/src/masks/maskCpf.ts @@ -0,0 +1,5 @@ +import maskValue from './maskValue'; + +export default function maskCpf(cpf: string): string { + return maskValue(cpf, 'cpf'); +} diff --git a/src/masks/maskCpfOrCnpj.ts b/src/masks/maskCpfOrCnpj.ts new file mode 100644 index 0000000..34bd1e2 --- /dev/null +++ b/src/masks/maskCpfOrCnpj.ts @@ -0,0 +1,5 @@ +import maskValue from './maskValue'; + +export default function maskCpfOrCnpj(value: string): string { + return maskValue(value, 'cpf_cnpj'); +} diff --git a/src/masks/maskHintBankAccount.ts b/src/masks/maskHintBankAccount.ts new file mode 100644 index 0000000..ad81087 --- /dev/null +++ b/src/masks/maskHintBankAccount.ts @@ -0,0 +1,14 @@ +import { BANK_ACCOUNT_MASKS } from './masks'; +import type { BankCompensationCode } from './types'; + +const DEFAULT_HINT = '0000000000-0'; + +const toHint = (pattern: string): string => pattern.replace(/[9S]/g, '0'); + +export default function maskHintBankAccount(compensationCode: string): string { + const pattern = BANK_ACCOUNT_MASKS[compensationCode as BankCompensationCode]; + + if (!pattern) return DEFAULT_HINT; + + return toHint(pattern); +} diff --git a/src/masks/maskInput.ts b/src/masks/maskInput.ts new file mode 100644 index 0000000..036e447 --- /dev/null +++ b/src/masks/maskInput.ts @@ -0,0 +1,21 @@ +import maskValue from './maskValue'; +import type { MaskType, CurrencyMaskOptions } from './types'; + +export default function maskInput( + input: HTMLInputElement, + type: MaskType, + options?: CurrencyMaskOptions, +): () => void { + if (!input) return () => {}; + + const handler = (): void => { + input.value = maskValue(input.value, type, options); + }; + + input.value = maskValue(input.value, type, options); + input.addEventListener('input', handler); + + return () => { + input.removeEventListener('input', handler); + }; +} diff --git a/src/masks/maskValue.ts b/src/masks/maskValue.ts new file mode 100644 index 0000000..343b73a --- /dev/null +++ b/src/masks/maskValue.ts @@ -0,0 +1,55 @@ +import VMasker from 'vanilla-masker'; +import { MASKS, CURRENCY_MASK_DEFAULTS, PERCENTAGE_MASK_DEFAULTS } from './masks'; +import { stripAlphanumeric, stripNumeric } from './strip'; +import type { MaskType, CurrencyMaskOptions } from './types'; + +const ALPHANUMERIC_MASKS: ReadonlyArray = ['cnpj', 'cpf_cnpj']; + +const stripFor = (value: string, type: MaskType): string => ( + ALPHANUMERIC_MASKS.includes(type) ? stripAlphanumeric(value) : stripNumeric(value) +); + +const patternFor = (type: MaskType, stripped: string): string => { + if (type === 'cpf_cnpj') { + if (/[A-Z]/.test(stripped)) return MASKS.CNPJ; + return stripped.length <= 11 ? MASKS.CPF : MASKS.CNPJ; + } + + switch (type) { + case 'cpf': return MASKS.CPF; + case 'cnpj': return MASKS.CNPJ; + case 'phone': return MASKS.PHONE; + case 'zipcode': return MASKS.ZIPCODE; + case 'date': return MASKS.DATE; + case 'barCode': return MASKS.BAR_CODE; + case 'barCodeUtilities': return MASKS.BAR_CODE_UTILITIES; + case 'darf': return MASKS.DARF; + case 'number': return MASKS.NUMBER; + default: return ''; + } +}; + +export default function maskValue( + value: string | null | undefined, + type: MaskType, + options?: CurrencyMaskOptions, +): string { + if (value === null || value === undefined || value === '') return ''; + + const stringValue = String(value); + + if (type === 'currency') { + return VMasker.toMoney(stringValue, { ...CURRENCY_MASK_DEFAULTS, ...(options || {}) }); + } + + if (type === 'percentage') { + return VMasker.toMoney(stringValue, { ...PERCENTAGE_MASK_DEFAULTS, ...(options || {}) }); + } + + const stripped = stripFor(stringValue, type); + const pattern = patternFor(type, stripped); + + if (!pattern) return stringValue; + + return VMasker.toPattern(stripped, pattern); +} diff --git a/src/masks/masks.ts b/src/masks/masks.ts new file mode 100644 index 0000000..ec7572d --- /dev/null +++ b/src/masks/masks.ts @@ -0,0 +1,42 @@ +import type { CurrencyMaskOptions, BankCompensationCode } from './types'; + +export const MASKS = { + CPF: '999.999.999-99', + CNPJ: 'SS.SSS.SSS/SSSS-99', + CPF_CNPJ: '999.999.999-99', + PHONE: '(99) 99999-9999', + ZIPCODE: '99999-999', + DATE: '99/99/9999', + BAR_CODE: '99999.99999 99999.999999 99999.999999 9 99999999999999', + BAR_CODE_UTILITIES: '999999999999 999999999999 999999999999 999999999999', + DARF: '99.999.999-9', + NUMBER: '999999999999', +} as const; + +export const CURRENCY_MASK_DEFAULTS: CurrencyMaskOptions = { + precision: 2, + separator: ',', + delimiter: '.', + unit: '', + zeroCents: false, +}; + +export const PERCENTAGE_MASK_DEFAULTS: CurrencyMaskOptions = { + precision: 2, + separator: ',', + delimiter: '.', + suffixUnit: '%', + zeroCents: false, +}; + +export const BANK_ACCOUNT_MASKS: Record = { + 1: '99999999-S', + 33: '99999999-9', + 41: '999999999-9', + 104: '999999999999-9', + 213: '9999999-9', + 237: '9999999-9', + 341: '99999-9', + 399: '999999-9', + 745: '9999999-9', +}; diff --git a/src/masks/setupMultipleMask.ts b/src/masks/setupMultipleMask.ts new file mode 100644 index 0000000..d560934 --- /dev/null +++ b/src/masks/setupMultipleMask.ts @@ -0,0 +1,16 @@ +import maskCpfOrCnpj from './maskCpfOrCnpj'; + +export default function setupMultipleMask(input: HTMLInputElement): () => void { + if (!input) return () => {}; + + const handler = (): void => { + input.value = maskCpfOrCnpj(input.value); + }; + + input.value = maskCpfOrCnpj(input.value); + input.addEventListener('input', handler); + + return () => { + input.removeEventListener('input', handler); + }; +} diff --git a/src/masks/strip.ts b/src/masks/strip.ts new file mode 100644 index 0000000..de444c7 --- /dev/null +++ b/src/masks/strip.ts @@ -0,0 +1,7 @@ +export const ALPHANUMERIC_STRIP_REGEX = /[^A-Za-z0-9]/g; +export const NUMERIC_STRIP_REGEX = /\D/g; +export const PATTERN_PLACEHOLDER_REGEX = /[9AS#]/g; + +export const stripAlphanumeric = (value: string): string => value.replace(ALPHANUMERIC_STRIP_REGEX, '').toUpperCase(); + +export const stripNumeric = (value: string): string => value.replace(NUMERIC_STRIP_REGEX, ''); diff --git a/src/masks/types.ts b/src/masks/types.ts new file mode 100644 index 0000000..0f006ac --- /dev/null +++ b/src/masks/types.ts @@ -0,0 +1,33 @@ +export type MaskType = + | 'cpf' + | 'cnpj' + | 'cpf_cnpj' + | 'phone' + | 'zipcode' + | 'date' + | 'barCode' + | 'barCodeUtilities' + | 'darf' + | 'number' + | 'currency' + | 'percentage'; + +export interface CurrencyMaskOptions { + precision?: number; + separator?: string; + delimiter?: string; + unit?: string; + suffixUnit?: string; + zeroCents?: boolean; +} + +export type BankCompensationCode = + | '1' + | '33' + | '41' + | '104' + | '213' + | '237' + | '341' + | '399' + | '745'; diff --git a/src/masks/vanilla-masker.d.ts b/src/masks/vanilla-masker.d.ts new file mode 100644 index 0000000..8febbb0 --- /dev/null +++ b/src/masks/vanilla-masker.d.ts @@ -0,0 +1,35 @@ +declare module 'vanilla-masker' { + export interface ToMoneyOptions { + precision?: number; + separator?: string; + delimiter?: string; + unit?: string; + suffixUnit?: string; + zeroCents?: boolean; + showSignal?: boolean; + lastOutput?: string; + } + + export interface ToPatternOptions { + placeholder?: string; + } + + interface VanillaMaskerStatic { + toMoney(value: string | number, opts?: ToMoneyOptions): string; + toNumber(value: string | number): string; + toAlphaNumeric(value: string): string; + toPattern(value: string | number, pattern: string | ToPatternOptions): string; + } + + const VMasker: ((el: HTMLElement | HTMLInputElement | HTMLElement[]) => { + maskMoney(opts?: ToMoneyOptions): void; + maskNumber(): void; + maskAlphaNum(): void; + maskPattern(pattern: string): void; + unMask(): void; + unbindElementToMask(): void; + }) & + VanillaMaskerStatic; + + export default VMasker; +} diff --git a/src/normalizers/normalizeCpfOrCnpj.ts b/src/normalizers/normalizeCpfOrCnpj.ts index 9a78f77..befbd7d 100644 --- a/src/normalizers/normalizeCpfOrCnpj.ts +++ b/src/normalizers/normalizeCpfOrCnpj.ts @@ -1,9 +1,16 @@ +import VMasker from 'vanilla-masker'; import maskString from '../utils/maskString'; +import { MASKS } from '../masks/masks'; +import { stripAlphanumeric } from '../masks/strip'; export default function normalizeCpfOrCnpj(value: string): string { - if (value?.length === 11) { + if (!value) return ''; + + if (value.length === 11) { return maskString(value, '###.###.###-##'); } - return maskString(value, '##.###.###/####-##'); + const stripped = stripAlphanumeric(value).slice(0, 14); + + return VMasker.toPattern(stripped, MASKS.CNPJ); } diff --git a/src/validations/isCnpj.ts b/src/validations/isCnpj.ts index 40b9cea..d2ffee0 100644 --- a/src/validations/isCnpj.ts +++ b/src/validations/isCnpj.ts @@ -1,47 +1,17 @@ -import stripNumbers from '../utils/stripNumbers'; - -const BLACKLIST: Array = [ - '00000000000000', - '11111111111111', - '22222222222222', - '33333333333333', - '44444444444444', - '55555555555555', - '66666666666666', - '77777777777777', - '88888888888888', - '99999999999999', -]; - -const STRICT_STRIP_REGEX: RegExp = /[-\\/.]/g; - -export function verifierDigit(digits: string): number { - let index: number = 2; - - const reverse: any = digits.split('').reduce((buffer: any, number: any) => [parseInt(number, 10)].concat(buffer), []); - - const sum: number = reverse.reduce((buffer: any, number: any) => { - buffer += number * index; - index = index === 9 ? 2 : index + 1; - return buffer; - }, 0); - - const mod: number = sum % 11; - - return mod < 2 ? 0 : 11 - mod; -} - -export default function isCnpj(number: string, strict?: boolean): boolean { - const regex: RegExp | undefined = strict ? STRICT_STRIP_REGEX : undefined; - const stripped: string = stripNumbers(number, regex); - - if (!stripped || stripped.length !== 14 || BLACKLIST.includes(stripped)) { - return false; - } - - let numbers: string = stripped.substr(0, 12); - numbers += verifierDigit(numbers); - numbers += verifierDigit(numbers); - - return numbers.substr(-2) === stripped.substr(-2); +import { cnpj } from 'cpf-cnpj-validator'; + +/** + * Valida um CNPJ (numérico ou alfanumérico). + * + * Aceita o formato legado (14 dígitos) e o novo formato alfanumérico da + * Nota Técnica RFB 49/2024 (12 alfanuméricos + 2 dígitos verificadores). + * + * @param value CNPJ a ser validado, com ou sem formatação. + * @param _strict @deprecated Parâmetro mantido por retrocompatibilidade — + * `cpf-cnpj-validator` aceita ambos os formatos sem necessidade de flag. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function isCnpj(value: string, _strict?: boolean): boolean { + if (!value) return false; + return cnpj.isValid(value.toUpperCase()); } diff --git a/tests/formatCnpj.spec.ts b/tests/formatCnpj.spec.ts new file mode 100644 index 0000000..06b8cd9 --- /dev/null +++ b/tests/formatCnpj.spec.ts @@ -0,0 +1,29 @@ +import 'jest'; + +import { formatCnpj } from '../src'; + +describe('formatCnpj', () => { + test('CNPJ numérico — retrocompat', () => { + expect(formatCnpj('11222333000181')).toBe('11.222.333/0001-81'); + }); + + test('CNPJ alfanumérico', () => { + expect(formatCnpj('12ABC34501DE35')).toBe('12.ABC.345/01DE-35'); + }); + + test('CNPJ alfanumérico minúsculo é normalizado', () => { + expect(formatCnpj('12abc34501de35')).toBe('12.ABC.345/01DE-35'); + }); + + test('strip de separadores em paste', () => { + expect(formatCnpj('12.ABC.345/01DE-35')).toBe('12.ABC.345/01DE-35'); + }); + + test('valor vazio', () => { + expect(formatCnpj('')).toBe(''); + }); + + test('valor parcial é parcialmente formatado', () => { + expect(formatCnpj('12ABC')).toBe('12.ABC'); + }); +}); diff --git a/tests/isCnpj.spec.ts b/tests/isCnpj.spec.ts index d5eff41..109a03c 100644 --- a/tests/isCnpj.spec.ts +++ b/tests/isCnpj.spec.ts @@ -1,5 +1,6 @@ import 'jest'; +import { cnpj } from 'cpf-cnpj-validator'; import { isCnpj } from '../src'; describe('test isCnpj', () => { @@ -15,15 +16,34 @@ describe('test isCnpj', () => { expect(isCnpj('88888888888888')).toBe(false); expect(isCnpj('99999999999999')).toBe(false); }); + test('spec list cnpj valid', () => { expect(isCnpj('37917940000150')).toBe(true); expect(isCnpj('84541997000187')).toBe(true); expect(isCnpj('90067886000183')).toBe(true); }); + test('spec list cnpj invalid', () => { expect(isCnpj('10132002256')).toBe(false); expect(isCnpj('10113200536')).toBe(false); expect(isCnpj('02213415285')).toBe(false); }); -}); + test('CNPJ alfanumérico válido (gerado pela lib)', () => { + const generated = cnpj.generate(); + expect(isCnpj(generated)).toBe(true); + }); + + test('CNPJ alfanumérico minúsculo é normalizado para maiúsculo', () => { + const generated = cnpj.generate(); + expect(isCnpj(generated.toLowerCase())).toBe(true); + }); + + test('CNPJ alfanumérico inválido (DV errado)', () => { + expect(isCnpj('12ABC34501DE99')).toBe(false); + }); + + test('valor vazio retorna false', () => { + expect(isCnpj('')).toBe(false); + }); +}); diff --git a/tests/masks/maskComplete.spec.ts b/tests/masks/maskComplete.spec.ts new file mode 100644 index 0000000..6a4e8bc --- /dev/null +++ b/tests/masks/maskComplete.spec.ts @@ -0,0 +1,45 @@ +import 'jest'; + +import { maskComplete } from '../../src'; + +describe('maskComplete', () => { + test('CPF completo', () => { + expect(maskComplete('932.310.970-37', 'cpf')).toBe(true); + }); + + test('CPF incompleto', () => { + expect(maskComplete('932.310', 'cpf')).toBe(false); + }); + + test('CNPJ numérico completo', () => { + expect(maskComplete('11.222.333/0001-81', 'cnpj')).toBe(true); + }); + + test('CNPJ alfanumérico completo', () => { + expect(maskComplete('12.ABC.345/01DE-35', 'cnpj')).toBe(true); + }); + + test('CNPJ incompleto', () => { + expect(maskComplete('12.ABC', 'cnpj')).toBe(false); + }); + + test('cpf_cnpj com 11 chars limpos é completo', () => { + expect(maskComplete('932.310.970-37', 'cpf_cnpj')).toBe(true); + }); + + test('cpf_cnpj com 14 chars limpos é completo', () => { + expect(maskComplete('12.ABC.345/01DE-35', 'cpf_cnpj')).toBe(true); + }); + + test('valor vazio é incompleto', () => { + expect(maskComplete('', 'cpf')).toBe(false); + }); + + test('phone completo', () => { + expect(maskComplete('(11) 99999-8888', 'phone')).toBe(true); + }); + + test('zipcode completo', () => { + expect(maskComplete('01310-100', 'zipcode')).toBe(true); + }); +}); diff --git a/tests/masks/maskCpfOrCnpj.spec.ts b/tests/masks/maskCpfOrCnpj.spec.ts new file mode 100644 index 0000000..6fa36de --- /dev/null +++ b/tests/masks/maskCpfOrCnpj.spec.ts @@ -0,0 +1,31 @@ +import 'jest'; + +import { maskCpfOrCnpj } from '../../src'; + +describe('maskCpfOrCnpj', () => { + test('aplica máscara de CPF quando ≤11 caracteres limpos', () => { + expect(maskCpfOrCnpj('93231097037')).toBe('932.310.970-37'); + }); + + test('aplica máscara de CNPJ quando >11 caracteres limpos', () => { + expect(maskCpfOrCnpj('11222333000181')).toBe('11.222.333/0001-81'); + }); + + test('aplica máscara de CNPJ alfanumérico quando >11 caracteres com letras', () => { + expect(maskCpfOrCnpj('12ABC34501DE35')).toBe('12.ABC.345/01DE-35'); + }); + + test('troca para CNPJ assim que aparecer uma letra (digitação progressiva)', () => { + expect(maskCpfOrCnpj('12A')).toBe('12.A'); + expect(maskCpfOrCnpj('12AB')).toBe('12.AB'); + expect(maskCpfOrCnpj('12ABC')).toBe('12.ABC'); + }); + + test('normaliza alfanumérico para uppercase', () => { + expect(maskCpfOrCnpj('12abc34501de35')).toBe('12.ABC.345/01DE-35'); + }); + + test('valor vazio retorna string vazia', () => { + expect(maskCpfOrCnpj('')).toBe(''); + }); +}); diff --git a/tests/masks/maskHintBankAccount.spec.ts b/tests/masks/maskHintBankAccount.spec.ts new file mode 100644 index 0000000..4fc857a --- /dev/null +++ b/tests/masks/maskHintBankAccount.spec.ts @@ -0,0 +1,25 @@ +import 'jest'; + +import { maskHintBankAccount } from '../../src'; + +describe('maskHintBankAccount', () => { + test('Banco do Brasil (1)', () => { + expect(maskHintBankAccount('1')).toBe('00000000-0'); + }); + + test('Itaú (341)', () => { + expect(maskHintBankAccount('341')).toBe('00000-0'); + }); + + test('Bradesco (237)', () => { + expect(maskHintBankAccount('237')).toBe('0000000-0'); + }); + + test('CEF (104)', () => { + expect(maskHintBankAccount('104')).toBe('000000000000-0'); + }); + + test('código desconhecido retorna placeholder default', () => { + expect(maskHintBankAccount('999')).toBe('0000000000-0'); + }); +}); diff --git a/tests/masks/maskInput.spec.ts b/tests/masks/maskInput.spec.ts new file mode 100644 index 0000000..58aeb37 --- /dev/null +++ b/tests/masks/maskInput.spec.ts @@ -0,0 +1,66 @@ +/** + * @jest-environment jsdom + */ +import 'jest'; + +import { maskInput } from '../../src'; + +describe('maskInput', () => { + test('aplica máscara ao input quando o usuário digita', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + const teardown = maskInput(input, 'cpf'); + + input.value = '93231097037'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.value).toBe('932.310.970-37'); + + teardown(); + document.body.removeChild(input); + }); + + test('CNPJ alfanumérico via input event', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + const teardown = maskInput(input, 'cnpj'); + + input.value = '12abc34501de35'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.value).toBe('12.ABC.345/01DE-35'); + + teardown(); + document.body.removeChild(input); + }); + + test('teardown remove o listener', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + const teardown = maskInput(input, 'cpf'); + teardown(); + + input.value = '93231097037'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.value).toBe('93231097037'); + + document.body.removeChild(input); + }); + + test('valor inicial já é mascarado ao chamar maskInput', () => { + const input = document.createElement('input'); + input.value = '93231097037'; + document.body.appendChild(input); + + const teardown = maskInput(input, 'cpf'); + + expect(input.value).toBe('932.310.970-37'); + + teardown(); + document.body.removeChild(input); + }); +}); diff --git a/tests/masks/maskValue.spec.ts b/tests/masks/maskValue.spec.ts new file mode 100644 index 0000000..fcb7130 --- /dev/null +++ b/tests/masks/maskValue.spec.ts @@ -0,0 +1,80 @@ +import 'jest'; + +import { maskValue } from '../../src'; + +describe('maskValue', () => { + describe('cpf', () => { + test('formata CPF', () => { + expect(maskValue('93231097037', 'cpf')).toBe('932.310.970-37'); + }); + test('strip de caracteres antes de aplicar', () => { + expect(maskValue('932.310.970-37', 'cpf')).toBe('932.310.970-37'); + }); + }); + + describe('cnpj numérico', () => { + test('formata CNPJ numérico', () => { + expect(maskValue('11222333000181', 'cnpj')).toBe('11.222.333/0001-81'); + }); + }); + + describe('cnpj alfanumérico', () => { + test('formata CNPJ alfanumérico', () => { + expect(maskValue('12ABC34501DE35', 'cnpj')).toBe('12.ABC.345/01DE-35'); + }); + test('normaliza para uppercase', () => { + expect(maskValue('12abc34501de35', 'cnpj')).toBe('12.ABC.345/01DE-35'); + }); + test('strip de separadores em paste', () => { + expect(maskValue('12.ABC.345/01DE-35', 'cnpj')).toBe('12.ABC.345/01DE-35'); + }); + test('formatação parcial sem throw', () => { + expect(maskValue('12ABC', 'cnpj')).toBe('12.ABC'); + }); + }); + + describe('phone', () => { + test('formata celular', () => { + expect(maskValue('11999998888', 'phone')).toBe('(11) 99999-8888'); + }); + }); + + describe('zipcode', () => { + test('formata CEP', () => { + expect(maskValue('01310100', 'zipcode')).toBe('01310-100'); + }); + }); + + describe('date', () => { + test('formata data', () => { + expect(maskValue('01012026', 'date')).toBe('01/01/2026'); + }); + }); + + describe('currency', () => { + test('formata BRL com defaults', () => { + expect(maskValue('150000', 'currency')).toBe('1.500,00'); + }); + test('formata com unit override', () => { + expect(maskValue('150000', 'currency', { unit: 'R$' })).toBe('R$ 1.500,00'); + }); + }); + + describe('percentage', () => { + test('formata percentual', () => { + expect(maskValue('1500', 'percentage')).toBe('15,00 %'); + }); + }); + + describe('edge cases', () => { + test('null retorna empty string', () => { + expect(maskValue(null, 'cpf')).toBe(''); + }); + test('undefined retorna empty string', () => { + expect(maskValue(undefined, 'cpf')).toBe(''); + }); + test('empty string retorna empty string', () => { + expect(maskValue('', 'cpf')).toBe(''); + }); + }); +}); diff --git a/tests/masks/setupMultipleMask.spec.ts b/tests/masks/setupMultipleMask.spec.ts new file mode 100644 index 0000000..054a984 --- /dev/null +++ b/tests/masks/setupMultipleMask.spec.ts @@ -0,0 +1,53 @@ +/** + * @jest-environment jsdom + */ +import 'jest'; + +import { setupMultipleMask } from '../../src'; + +describe('setupMultipleMask', () => { + test('aplica CPF quando ≤11 chars limpos', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + const teardown = setupMultipleMask(input); + + input.value = '93231097037'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.value).toBe('932.310.970-37'); + + teardown(); + document.body.removeChild(input); + }); + + test('transição CPF → CNPJ ao digitar 12º caractere', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + const teardown = setupMultipleMask(input); + + input.value = '932310970371'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.value).toBe('93.231.097/0371'); + + teardown(); + document.body.removeChild(input); + }); + + test('CNPJ alfanumérico via setupMultipleMask', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + const teardown = setupMultipleMask(input); + + input.value = '12abc34501de35'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(input.value).toBe('12.ABC.345/01DE-35'); + + teardown(); + document.body.removeChild(input); + }); +}); diff --git a/tests/normalizeCpfOrCnpj.spec.ts b/tests/normalizeCpfOrCnpj.spec.ts index 5a5a7b2..02088f7 100644 --- a/tests/normalizeCpfOrCnpj.spec.ts +++ b/tests/normalizeCpfOrCnpj.spec.ts @@ -13,5 +13,13 @@ describe('test isCPF', () => { expect(normalizeCpfOrCnpj('80985211000160')).toBe('80.985.211/0001-60'); expect(normalizeCpfOrCnpj('11674478000113')).toBe('11.674.478/0001-13'); }); + test('CNPJ alfanumérico', () => { + expect(normalizeCpfOrCnpj('12ABC34501DE35')).toBe('12.ABC.345/01DE-35'); + }); + test('CNPJ alfanumérico minúsculo', () => { + expect(normalizeCpfOrCnpj('12abc34501de35')).toBe('12.ABC.345/01DE-35'); + }); + test('valor vazio', () => { + expect(normalizeCpfOrCnpj('')).toBe(''); + }); }); - diff --git a/tests/normalizeDate.spec.ts b/tests/normalizeDate.spec.ts index d1df753..0ba2d61 100644 --- a/tests/normalizeDate.spec.ts +++ b/tests/normalizeDate.spec.ts @@ -5,10 +5,10 @@ describe('normalizeDate', () => { test('should return the expected formatted date for type "bigger"', () => { const result = normalizeDate('2023-12-12 23:39:25.756Z', 'bigger'); expect(result).toBe( - 'terça-feira, 12 de dezembro de 2023 23:39:25 Horário Padrão de Brasília', + 'terça-feira, 12 de dezembro de 2023 às 23:39:25 Horário Padrão de Brasília', ); expect(result).not.toBe( - 'terça-feira, 12 de dezembro de 2023 0:00:00 Horário Padrão de Brasília', + 'terça-feira, 12 de dezembro de 2023 às 0:00:00 Horário Padrão de Brasília', ); });