From 43b0bd1ac75f3cbf19132ceb626703099296e87e Mon Sep 17 00:00:00 2001 From: Mateus Teruki Matubara Date: Wed, 21 Jan 2026 09:45:27 -0300 Subject: [PATCH 1/2] feat: add joi extension and test --- package-lock.json | 68 +++++++++++++++++++++++++++++++++++++-- package.json | 27 +++++++++++++--- src/__tests__/cnh.test.ts | 2 +- src/__tests__/joi.test.ts | 54 +++++++++++++++++++++++++++++++ src/cnh.ts | 32 ++++++++++++++---- src/index.ts | 35 ++++++++++++++++++++ src/joi.ts | 68 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/joi.test.ts create mode 100644 src/joi.ts diff --git a/package-lock.json b/package-lock.json index 7846ed0..3e2d4da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,28 @@ { "name": "br-docs", - "version": "1.1.3", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "br-docs", - "version": "1.1.3", + "version": "1.1.7", "license": "ISC", "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.37.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^8.50.0", + "joi": "^17.0.0", "typescript": "^5.8.3", "vitest": "^3.2.4" + }, + "peerDependencies": { + "joi": "^17.0.0" + }, + "peerDependenciesMeta": { + "joi": { + "optional": true + } } }, "node_modules/@ampproject/remapping": { @@ -629,6 +638,23 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1186,6 +1212,30 @@ "win32" ] }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2563,6 +2613,20 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", diff --git a/package.json b/package.json index 4198979..b4db303 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "br-docs", - "version": "1.1.3", + "version": "1.1.7", "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", @@ -21,7 +21,8 @@ "./cnh": "./dist/cnh.js", "./cep": "./dist/cep.js", "./telefone": "./dist/telefone.js", - "./boleto": "./dist/boleto.js" + "./boleto": "./dist/boleto.js", + "./nis": "./dist/nis.js" }, "scripts": { "build": "tsc", @@ -39,7 +40,16 @@ "cpf", "phone", "cep", - "cnpj" + "cnpj", + "pix", + "boleto", + "cnh", + "nis", + "uuid", + "email", + "documentos", + "validacao", + "formatacao" ], "author": "Mateus Teruki", "description": "Utilitários para validação e formatação de documentos brasileiros como CPF, telefone, CEP e CNPJ.", @@ -49,7 +59,16 @@ "@vitest/coverage-v8": "^3.2.4", "eslint": "^8.50.0", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "joi": "^17.0.0" + }, + "peerDependencies": { + "joi": "^17.0.0" + }, + "peerDependenciesMeta": { + "joi": { + "optional": true + } }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/__tests__/cnh.test.ts b/src/__tests__/cnh.test.ts index c038a54..a54b7bc 100644 --- a/src/__tests__/cnh.test.ts +++ b/src/__tests__/cnh.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; describe("CNH Validation", () => { it("should validate a correct CNH", () => { - expect(Cnh.isValid("12345678900")).toBe(true); + expect(Cnh.isValid("60930978987")).toBe(true); }); it("should invalidate an incorrect CNH", () => { diff --git a/src/__tests__/joi.test.ts b/src/__tests__/joi.test.ts new file mode 100644 index 0000000..4f80f01 --- /dev/null +++ b/src/__tests__/joi.test.ts @@ -0,0 +1,54 @@ +import BaseJoi, { Schema } from "joi"; +import validator from "../joi"; +import { describe, expect, it } from "vitest"; + +const Joi = BaseJoi.extend(validator); + +function expectOk(schema: Schema, value: string) { + const { error } = schema.validate(value); + expect(error).toBeUndefined(); +} + +function expectErr(schema: Schema, value: string, code: string) { + const { error } = schema.validate(value); + expect(error).toBeTruthy(); + expect(error!.details[0].type).toBe(code); +} + +describe("br-docs Joi extension", () => { + it("cpf", () => { + expectOk(Joi.document().cpf(), "529.982.247-25"); + expectErr(Joi.document().cpf(), "123", "document.cpf"); + }); + + it("cnpj", () => { + expectOk(Joi.document().cnpj(), "04.252.011/0001-10"); + expectErr(Joi.document().cnpj(), "123", "document.cnpj"); + }); + + it("nis", () => { + expectOk(Joi.document().nis(), "16029214650"); + expectErr(Joi.document().nis(), "16029214651", "document.nis"); + }); + + it("cnh", () => { + expectOk(Joi.document().cnh(), "60930978987"); + expectErr(Joi.document().cnh(), "11111111111", "document.cnh"); + }); + + it("cep / phone / celular / uuid / pix", () => { + expectOk(Joi.document().cep(), "01001-000"); + expectOk(Joi.document().phone(), "1133334444"); + expectOk(Joi.document().celular(), "11999998888"); + expectOk(Joi.document().uuid(), "550e8400-e29b-41d4-a716-446655440000"); + expectOk(Joi.document().pix(), "a@b.com"); + }); + + it("boleto", () => { + expectOk( + Joi.document().boleto(), + "00190.00009 02372.190005 00017.356171 9 63690000007179", + ); + expectErr(Joi.document().boleto(), "123", "document.boleto"); + }); +}); diff --git a/src/cnh.ts b/src/cnh.ts index d58b6fa..a168772 100644 --- a/src/cnh.ts +++ b/src/cnh.ts @@ -40,14 +40,32 @@ export class Cnh implements IDocumentHandler { const cleanCnh = clean(cnh); - const firstDigit = calcDigit(cleanCnh.slice(0, 9)); - const secondDigit = calcDigit(cleanCnh.slice(0, 10)); + if (cleanCnh.length !== 11) return false; + if (/^(\d)\1{10}$/.test(cleanCnh)) return false; - return ( - cleanCnh.length === 11 && - cleanCnh[9] === firstDigit.toString() && - cleanCnh[10] === secondDigit.toString() - ); + const base = cleanCnh.slice(0, 9).split("").map(Number); + + let sum1 = 0; + let sum2 = 0; + + for (let i = 0; i < 9; i++) { + sum1 += base[i] * (9 - i); + sum2 += base[i] * (1 + i); + } + + const dv1Raw = sum1 % 11; + const dv1 = dv1Raw > 9 ? 0 : dv1Raw; + + let dv2 = sum2 % 11; + + if (dv1Raw > 9) { + dv2 = dv2 - 2; + if (dv2 < 0) dv2 += 9; + } + + if (dv2 > 9) dv2 = 0; + + return cleanCnh[9] === String(dv1) && cleanCnh[10] === String(dv2); } /** * Formata uma CNH brasileira. diff --git a/src/index.ts b/src/index.ts index 21af4a7..50ef222 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +import { Cnpj } from "./cnpj"; +import { Cpf } from "./cpf"; + /** * Interface que define o método de validação de um documento. * Implementa o método de validação que verifica se o documento é válido. @@ -45,3 +48,35 @@ export { Cep } from "./cep"; export { Telefone } from "./telefone"; export { Cnh } from "./cnh"; export { Boleto } from "./boleto"; +export { Nis } from "./nis"; + +export const validator = (joi: any) => ({ + type: "document", + base: joi.string(), + messages: { + "document.cpf": "CPF inválido", + "document.cnpj": "CNPJ inválido", + }, + rules: { + cpf: { + validate(value: any, helpers: any, args: any, options: any) { + if (!Cpf.isValid(value)) { + return helpers.error("document.cpf"); + } + + return value; + }, + }, + cnpj: { + validate(value: any, helpers: any, args: any, options: any) { + if (!Cnpj.isValid(value)) { + return helpers.error("document.cnpj"); + } + + return value; + }, + }, + }, +}); + +export default validator; diff --git a/src/joi.ts b/src/joi.ts new file mode 100644 index 0000000..e4bb349 --- /dev/null +++ b/src/joi.ts @@ -0,0 +1,68 @@ +import type { ExtensionFactory } from "joi"; + +import { Cpf } from "./cpf"; +import { Cnpj } from "./cnpj"; +import { Nis } from "./nis"; +import { Cnh } from "./cnh"; +import { Cep } from "./cep"; +import { Telefone } from "./telefone"; +import { Pix } from "./pix"; +import { Boleto } from "./boleto"; +import { Celular } from "./celular"; + +export const validator: ExtensionFactory = (joi) => ({ + type: "document", + base: joi.string(), + messages: { + "document.cpf": "CPF inválido", + "document.cnpj": "CNPJ inválido", + "document.nis": "NIS inválido", + "document.cnh": "CNH inválida", + "document.cep": "CEP inválido", + "document.phone": "Telefone inválido", + "document.uuid": "UUID inválido", + "document.pix": "Chave PIX inválida", + "document.boleto": "Boleto inválido", + "document.celular": "Celular inválido", + }, + rules: { + cpf: { + validate: (value, helpers) => + Cpf.isValid(value) ? value : helpers.error("document.cpf"), + }, + cnpj: { + validate: (value, helpers) => + Cnpj.isValid(value) ? value : helpers.error("document.cnpj"), + }, + nis: { + validate: (value, helpers) => + Nis.isValid(value) ? value : helpers.error("document.nis"), + }, + cnh: { + validate: (value, helpers) => + Cnh.isValid(value) ? value : helpers.error("document.cnh"), + }, + cep: { + validate: (value, helpers) => + Cep.isValid(value) ? value : helpers.error("document.cep"), + }, + phone: { + validate: (value, helpers) => + Telefone.isValid(value) ? value : helpers.error("document.phone"), + }, + pix: { + validate: (value, helpers) => + Pix.isValid(value) ? value : helpers.error("document.pix"), + }, + boleto: { + validate: (value, helpers) => + Boleto.isValid(value) ? value : helpers.error("document.boleto"), + }, + celular: { + validate: (value, helpers) => + Celular.isValid(value) ? value : helpers.error("document.celular"), + }, + }, +}); + +export default validator; From 25fba201a9bcb18c217b242388a733509e158cbf Mon Sep 17 00:00:00 2001 From: Mateus Teruki Matubara Date: Wed, 21 Jan 2026 10:03:49 -0300 Subject: [PATCH 2/2] feat: add joi docs and tests --- SUMMARY.md | 2 ++ docs/EXTENSOES/JOI.md | 74 ++++++++++++++++++++++++++++++++++++++++ docs/EXTENSOES/README.md | 3 ++ package.json | 3 +- src/cnh.ts | 13 ------- src/joi.ts | 2 +- 6 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 docs/EXTENSOES/JOI.md create mode 100644 docs/EXTENSOES/README.md diff --git a/SUMMARY.md b/SUMMARY.md index 6174a65..cfd51ea 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -17,3 +17,5 @@ - [Telefone/Celular](docs/UTILITARIOS/TELEFONE.md) - [UUID](docs/UTILITARIOS/UUID.md) - [Boleto](docs/UTILITARIOS/BOLETO.md) + - [Extensões](docs/EXTENSOES/README.md) + - [Joi](docs/EXTENSOES/JOI.md) diff --git a/docs/EXTENSOES/JOI.md b/docs/EXTENSOES/JOI.md new file mode 100644 index 0000000..9258111 --- /dev/null +++ b/docs/EXTENSOES/JOI.md @@ -0,0 +1,74 @@ +# Joi + +## Integração com br-docs + +A extensão de Joi do **br-docs** adiciona um novo tipo chamado `document` com regras específicas para documentos brasileiros. + +### Instalação + +```bash +npm i br-docs joi +``` + +### Uso básico + +```typescript +import Joi from "joi"; +import documentExtension from "br-docs/joi"; + +const JoiExtended = Joi.extend(documentExtension); + +const schema = JoiExtended.object({ + cpf: JoiExtended.document().cpf().required(), + cnpj: JoiExtended.document().cnpj().required(), + nis: JoiExtended.document().nis().required(), + cnh: JoiExtended.document().cnh().required(), + cep: JoiExtended.document().cep().required(), + telefone: JoiExtended.document().phone().required(), + pix: JoiExtended.document().pix().required(), + boleto: JoiExtended.document().boleto().required(), + celular: JoiExtended.document().celular().required(), +}); + +const result = schema.validate({ + cpf: "123.456.789-09", + cnpj: "11.222.333/0001-81", + nis: "123.45678.90-1", + cnh: "02650306461", + cep: "01001-000", + telefone: "(11) 3456-7890", + pix: "email@dominio.com", + boleto: "34191.79001 01043.510047 91020.150008 6 84570000002000", + celular: "(11) 98765-4321", +}); + +if (result.error) { + console.error(result.error.details); +} +``` + +## Regras disponíveis + +As regras abaixo validam o valor e retornam erro com mensagens específicas: + +- `document().cpf()` — CPF inválido +- `document().cnpj()` — CNPJ inválido +- `document().nis()` — NIS inválido +- `document().cnh()` — CNH inválida +- `document().cep()` — CEP inválido +- `document().phone()` — Telefone inválido +- `document().pix()` — Chave PIX inválida +- `document().boleto()` — Boleto inválido +- `document().celular()` — Celular inválido + +## Mensagens de erro personalizadas + +Você pode customizar mensagens normalmente pelo Joi: + +```typescript +const schema = JoiExtended.object({ + cpf: JoiExtended.document().cpf().messages({ + "document.cpf": "CPF informado não é válido", + }), +}); +``` diff --git a/docs/EXTENSOES/README.md b/docs/EXTENSOES/README.md new file mode 100644 index 0000000..876f2b4 --- /dev/null +++ b/docs/EXTENSOES/README.md @@ -0,0 +1,3 @@ +# Extensões + +Nesta seção você encontra integrações do **br-docs** com bibliotecas de validação e outros ecossistemas. diff --git a/package.json b/package.json index b4db303..912b05c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "./cep": "./dist/cep.js", "./telefone": "./dist/telefone.js", "./boleto": "./dist/boleto.js", - "./nis": "./dist/nis.js" + "./nis": "./dist/nis.js", + "./joi": "./dist/joi.js" }, "scripts": { "build": "tsc", diff --git a/src/cnh.ts b/src/cnh.ts index a168772..40982ad 100644 --- a/src/cnh.ts +++ b/src/cnh.ts @@ -1,19 +1,6 @@ import { IDocumentHandler } from "."; import clean from "./utils/cleanString"; -/** - * Função auxiliar para calcular o dígito verificador de um número. - * @param digits - * @returns {number} O dígito verificador calculado. - */ -const calcDigit = (digits: string): number => { - const digit = digits - .split("") - .reduce((acc, digit, index) => acc + parseInt(digit, 10) * (9 - index), 0); - const remainder = digit % 11; - return remainder < 2 ? 0 : 11 - remainder; -}; - export class Cnh implements IDocumentHandler { constructor(private cnh: string) {} diff --git a/src/joi.ts b/src/joi.ts index e4bb349..70cb985 100644 --- a/src/joi.ts +++ b/src/joi.ts @@ -10,7 +10,7 @@ import { Pix } from "./pix"; import { Boleto } from "./boleto"; import { Celular } from "./celular"; -export const validator: ExtensionFactory = (joi) => ({ +const validator: ExtensionFactory = (joi) => ({ type: "document", base: joi.string(), messages: {