diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml new file mode 100644 index 0000000..9c33e0c --- /dev/null +++ b/.github/workflows/test-suite.yml @@ -0,0 +1,43 @@ +name: Test Suite + +on: + push: + branches: [main] + pull_request: + +jobs: + unit: + name: ${{ matrix.test.name }} (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x] + test: + - name: "Parse CSV" + file: tests/parseCSV.test.js + - name: "Token Resolver" + file: tests/utils/tokenResolver.test.js + - name: "Condition Evaluation" + file: tests/utils/evaluateCondition.test.js + - name: "Validators" + file: tests/validators.test.js + - name: "Dataset Validation" + file: tests/validateDataset.test.js + - name: "Configuration Scenarios" + file: tests/configs.test.js + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Run targeted test file + run: node --test ${{ matrix.test.file }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ce77ea --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# csv-rule-validator + +Librería ligera para validar datasets en formato CSV o JSON mediante reglas declarativas. Permite describir validaciones campo a campo usando archivos JSON y enlazarse con lógica de negocio externa para escenarios avanzados. + +## Instalación + +```bash +npm install +``` + +## Uso básico + +```js +import { readFileSync } from 'fs'; +import { validateDataset } from 'csv-rule-validator'; + +const csv = readFileSync('./clientes.csv', 'utf8'); +const definition = JSON.parse(readFileSync('./configs/basic-onboarding.json', 'utf8')); + +const resultado = await validateDataset(csv, definition); + +if (!resultado.valid) { + console.table(resultado.errors); +} +``` + +## Definiciones en JSON + +Cada archivo de configuración describe reglas por columna y validaciones de fila opcionales: + +```json +{ + "columns": { + "dni": { + "rules": [ + { "type": "required" }, + { "type": "string", "length": 8 }, + { "type": "number", "integer": true } + ] + }, + "email": { + "rules": [ + { "type": "required" }, + { + "type": "string", + "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", + "message": "El email no tiene un formato válido" + } + ] + } + } +} +``` + +### Validaciones disponibles + +- `required`: verifica que exista un valor. +- `string`: longitudes mínimas/máximas, coincidencia de patrón, mayúsculas/minúsculas. +- `number`: admite `min`, `max` e `integer`. +- `in`: asegura que el valor pertenezca a un listado. +- `compare`: compara el valor con otro campo o constante (`operator`: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `contains`, `startsWith`, `endsWith`). +- `requiredIf`: vuelve obligatorio un campo cuando se cumple una condición declarativa (`when`). +- `custom`: delega la validación a funciones externas o personalizadas. + +Todas las reglas aceptan: + +- `message`: texto a devolver en caso de error. +- `code`: identificador opcional de error. +- `severity`: etiqueta libre (`warning`, `error`, etc.). +- `onlyIf`: condición declarativa para ejecutar la regla sólo en determinados casos. + +### Condiciones declarativas (`when`, `onlyIf`) + +Las condiciones usan la misma sintaxis tanto en reglas como en `rowValidations`: + +- `field`: nombre de la columna a evaluar (`$row.campo` implícito). +- `target`: token explícito (`$value`, `$row.otroCampo`, `$context.algo`). +- Operadores: `equals`, `notEquals`, `in`, `notIn`, `present`, `empty`, `minLength`, `maxLength`, `regex`, `gt`, `gte`, `lt`, `lte`, `truthy`, `falsy`. +- Combinadores lógicos: `all`, `any`, `not`. +- `external`: nombre de un resolver externo que devuelve `true`/`false`. + +### Validaciones por fila + +Además de las reglas por columna, podés describir `rowValidations` para ejecutar múltiples afirmaciones cuando la fila cumple cierta condición: + +```json +{ + "rowValidations": [ + { + "when": { "field": "estado", "equals": "aprobada" }, + "assertions": [ + { "column": "monto_desembolsado", "rule": { "type": "required" } }, + { "column": "fecha_aprobacion", "rule": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } } + ] + } + ] +} +``` + +## Integración con lógica de negocio + +La clave `custom` permite referenciar funciones declaradas en `context.resolvers`. Cada resolver recibe `(value, row, context, rule, args)` y puede devolver: + +- `true` si la validación es exitosa. +- `false` para usar el mensaje configurado en la regla. +- Un `string` con el mensaje de error. +- Un objeto `{ valid: boolean, message?: string, meta?: any }` para enviar metadata adicional. + +Ejemplo: + +```js +const context = { + resolvers: { + async dniExisteEnCore(value) { + const existe = await clientesService.existe(value); + return existe || 'El DNI no figura en el core'; + } + } +}; +``` + +## Configuraciones de ejemplo + +Se incluyen varios escenarios en la carpeta [`configs/`](./configs): + +- [`basic-onboarding.json`](./configs/basic-onboarding.json): reglas esenciales para altas. +- [`dependent-benefits.json`](./configs/dependent-benefits.json): dependencias entre columnas y validaciones de fila. +- [`external-checks.json`](./configs/external-checks.json): integración con sistemas externos. +- [`advanced-risk.json`](./configs/advanced-risk.json): escenarios complejos con múltiples condiciones combinadas. + +## Ejecutar el ejemplo + +```bash +node example.js +``` + +El script carga la configuración `external-checks.json`, simula resolvers externos y muestra los errores detectados. diff --git a/configs/advanced-risk.json b/configs/advanced-risk.json new file mode 100644 index 0000000..43ecbc6 --- /dev/null +++ b/configs/advanced-risk.json @@ -0,0 +1,105 @@ +{ + "description": "Escenario complejo para scoring de riesgo y calidad de datos", + "columns": { + "operacion_id": { + "rules": [ + { "type": "required" }, + { "type": "string", "pattern": "^OP-\\d{6}$", "message": "El ID debe seguir el patrón OP-######" } + ] + }, + "estado": { + "rules": [ + { "type": "required" }, + { "type": "in", "values": ["aprobada", "rechazada", "pendiente"] } + ] + }, + "motivo_rechazo": { + "rules": [ + { + "type": "requiredIf", + "when": { + "field": "estado", + "equals": "rechazada" + }, + "message": "Los rechazos requieren motivo" + } + ] + }, + "score": { + "rules": [ + { "type": "number", "min": 0, "max": 100 }, + { + "type": "compare", + "operator": "gte", + "left": "$value", + "right": "$row.score_minimo_perfil", + "onlyIf": { "field": "score_minimo_perfil", "present": true }, + "message": "El score está por debajo del mínimo permitido para el perfil" + } + ] + }, + "score_minimo_perfil": { + "rules": [ + { "type": "number", "min": 0, "max": 100 } + ] + }, + "flag_sospechoso": { + "rules": [ + { "type": "string", "pattern": "^(SI|NO)$", "message": "Usa SI o NO" } + ] + }, + "comentarios": { + "rules": [ + { + "type": "string", + "onlyIf": { + "any": [ + { "field": "flag_sospechoso", "equals": "SI" }, + { "field": "estado", "equals": "rechazada" } + ] + }, + "minLength": 10, + "message": "Incluí una explicación cuando la operación es riesgosa" + } + ] + } + }, + "rowValidations": [ + { + "when": { "field": "estado", "equals": "aprobada" }, + "assertions": [ + { + "column": "monto_desembolsado", + "rule": { "type": "required", "message": "Las operaciones aprobadas deben tener desembolso" } + }, + { + "column": "fecha_aprobacion", + "rule": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "message": "El formato de fecha de aprobación es inválido" + } + } + ] + }, + { + "when": { + "all": [ + { "field": "estado", "equals": "aprobada" }, + { "field": "flag_sospechoso", "equals": "SI" } + ] + }, + "assertions": [ + { + "column": "reporte_manual", + "rule": { + "type": "custom", + "resolver": "validarReporteManual", + "args": ["$row"], + "message": "Falta adjuntar el reporte manual para operaciones sospechosas aprobadas" + } + } + ] + } + ] +} diff --git a/configs/basic-onboarding.json b/configs/basic-onboarding.json new file mode 100644 index 0000000..1e8f1b2 --- /dev/null +++ b/configs/basic-onboarding.json @@ -0,0 +1,33 @@ +{ + "description": "Reglas básicas para validar un CSV de altas de clientes", + "columns": { + "dni": { + "rules": [ + { "type": "required", "message": "El DNI es obligatorio" }, + { "type": "string", "length": 8, "message": "El DNI debe tener 8 caracteres" }, + { "type": "number", "integer": true, "message": "El DNI debe ser numérico" } + ] + }, + "nombre": { + "rules": [ + { "type": "required" }, + { "type": "string", "minLength": 3, "maxLength": 120 } + ] + }, + "email": { + "rules": [ + { "type": "required" }, + { + "type": "string", + "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", + "message": "El email no tiene un formato válido" + } + ] + }, + "telefono": { + "rules": [ + { "type": "string", "onlyIf": { "present": true, "field": "telefono" }, "minLength": 9, "maxLength": 12 } + ] + } + } +} diff --git a/configs/dependent-benefits.json b/configs/dependent-benefits.json new file mode 100644 index 0000000..88aea58 --- /dev/null +++ b/configs/dependent-benefits.json @@ -0,0 +1,53 @@ +{ + "description": "Valida dependencias entre columnas para beneficios corporativos", + "columns": { + "tipo_empleado": { + "rules": [ + { "type": "required" }, + { "type": "in", "values": ["planta", "contratista", "practicante"] } + ] + }, + "fecha_ingreso": { + "rules": [ + { "type": "requiredIf", "when": { "field": "tipo_empleado", "equals": "planta" } }, + { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "message": "Usa formato YYYY-MM-DD" } + ] + }, + "salario": { + "rules": [ + { "type": "number", "min": 0 }, + { + "type": "custom", + "resolver": "validateMinimumSalary", + "args": ["$value", "$row.tipo_empleado"], + "message": "El salario no cumple el mínimo requerido para el tipo de empleado" + } + ] + }, + "bono": { + "rules": [ + { + "type": "compare", + "operator": "lte", + "left": "$value", + "right": "$row.salario", + "message": "El bono no puede ser mayor que el salario" + } + ] + } + }, + "rowValidations": [ + { + "when": { "field": "tipo_empleado", "equals": "planta" }, + "assertions": [ + { + "column": "beneficios_medicos", + "rule": { + "type": "required", + "message": "Los empleados de planta requieren información de beneficios médicos" + } + } + ] + } + ] +} diff --git a/configs/external-checks.json b/configs/external-checks.json new file mode 100644 index 0000000..9ab3990 --- /dev/null +++ b/configs/external-checks.json @@ -0,0 +1,40 @@ +{ + "description": "Usa resolvers externos para validar datos con la lógica del dominio", + "columns": { + "dni": { + "rules": [ + { "type": "required" }, + { "type": "string", "length": 8 }, + { + "type": "custom", + "resolver": "dniExisteEnCore", + "args": ["$value"], + "message": "El DNI indicado no existe en los sistemas core" + } + ] + }, + "cuenta": { + "rules": [ + { "type": "required" }, + { + "type": "custom", + "resolver": "cuentaHabilitadaParaAlta", + "args": ["$value", "$row.dni"], + "message": "La cuenta no admite nuevas altas para el DNI informado" + } + ] + }, + "monto": { + "rules": [ + { "type": "required" }, + { "type": "number", "min": 0 }, + { + "type": "custom", + "resolver": "montoDentroDeLimites", + "args": ["$value", "$row.cuenta"], + "message": "El monto excede los límites para la cuenta seleccionada" + } + ] + } + } +} diff --git a/example.js b/example.js deleted file mode 100644 index 4b48c30..0000000 --- a/example.js +++ /dev/null @@ -1,46 +0,0 @@ -import { validateDataset } from './index.js'; - -const csvData = ` -A,B,C,D -100,ingreso,, -200,egreso,,X001 -,ingreso,desc, -`; - -const definition = { - columns: { - A: { - rules: [ - { type: 'required' }, - { type: 'number', min: 0 }, - ] - }, - B: { - rules: [ - { type: 'in', values: ['ingreso', 'egreso'] } - ] - }, - C: { - rules: [ - { type: 'requiredIf', dependsOn: 'B', condition: (b) => b === 'egreso' } - ] - }, - D: { - rules: [ - { - type: 'custom', - validate: async (value) => { - const validCodes = ['X001', 'X002', 'X003']; - if (!value) return true; - return validCodes.includes(value) - ? true - : `Código ${value} no existe`; - } - } - ] - } - } -}; - -const result = await validateDataset(csvData, definition); -console.log(result); diff --git a/index.js b/index.js index 3293789..b74d5fb 100644 --- a/index.js +++ b/index.js @@ -1,27 +1,150 @@ import { parseCSV } from './utils/parseCSV.js'; import validators from './validators/index.js'; +import { evaluateCondition } from './utils/evaluateCondition.js'; + +function normaliseDefinition(definition) { + if (typeof definition === 'string') { + return JSON.parse(definition); + } + return definition; +} + +function normaliseRule(rule) { + if (typeof rule === 'string') { + return { type: rule }; + } + return rule; +} + +function normaliseValidatorResult(rule, result) { + if (result === true) { + return null; + } + + const baseMessage = rule.message || 'Validación no superada'; + + if (result === false) { + return { message: baseMessage }; + } + + if (typeof result === 'string') { + return { message: result }; + } + + if (result && typeof result === 'object') { + return { + message: result.message || baseMessage, + meta: result.meta, + }; + } + + return { message: baseMessage }; +} + +async function shouldRunRule(rule, evaluationContext) { + if (rule?.enabled === false) return false; + if (!rule?.onlyIf) return true; + return evaluateCondition(rule.onlyIf, evaluationContext); +} + +async function executeRule({ rule, validator, value, row, column, rowIndex, context, errors }) { + const evaluationContext = { value, row, context, rule }; + const run = await shouldRunRule(rule, evaluationContext); + if (!run) return; + + const result = await validator(value, row, rule, context); + const normalised = normaliseValidatorResult(rule, result); + if (!normalised) return; + + const error = { + row: rowIndex + 1, + column, + rule: rule.type, + message: normalised.message, + }; + + if (rule.code) { + error.code = rule.code; + } + + if (rule.severity) { + error.severity = rule.severity; + } + + if (normalised.meta !== undefined) { + error.meta = normalised.meta; + } + + errors.push(error); +} + +async function validateRowLevelRules({ row, rowIndex, definition, context, errors }) { + if (!definition?.rowValidations) return; + + for (const rowRule of definition.rowValidations) { + const shouldRun = await evaluateCondition(rowRule.when, { value: row, row, context, rule: rowRule }); + if (!shouldRun) continue; + + for (const assertion of rowRule.assertions ?? []) { + const rule = normaliseRule(assertion.rule ?? assertion); + const column = assertion.column ?? rule.field ?? rule.target ?? assertion.field ?? null; + const value = column ? row[column] : row; + const validator = validators[rule.type]; + if (!validator) { + throw new Error(`Validator "${rule.type}" no existe`); + } + await executeRule({ + rule, + validator, + value, + row, + column, + rowIndex, + context, + errors, + }); + } + } +} export async function validateDataset(input, definition, context = {}) { + const normalisedDefinition = normaliseDefinition(definition); const rows = Array.isArray(input) ? input : await parseCSV(input); const errors = []; + const columnsDefinition = normalisedDefinition.columns ?? {}; for (let i = 0; i < rows.length; i++) { const row = rows[i]; - for (const [col, colDef] of Object.entries(definition.columns)) { + + for (const [col, colDef] of Object.entries(columnsDefinition)) { const value = row[col]; - for (const rule of colDef.rules) { + const rules = (colDef?.rules ?? []).map(normaliseRule); + + for (const rule of rules) { const validator = validators[rule.type]; - if (!validator) throw new Error(`Validator "${rule.type}" no existe`); - const result = await validator(value, row, rule, context); - if (result !== true) { - errors.push({ - row: i + 1, - column: col, - message: result, - }); + if (!validator) { + throw new Error(`Validator "${rule.type}" no existe`); } + await executeRule({ + rule, + validator, + value, + row, + column: col, + rowIndex: i, + context, + errors, + }); } } + + await validateRowLevelRules({ + row, + rowIndex: i, + definition: normalisedDefinition, + context, + errors, + }); } return { valid: errors.length === 0, errors }; diff --git a/package.json b/package.json index 8a0dad5..2dea458 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Librería para validar datasets tipo CSV o JSON con reglas declarativas", "type": "module", "main": "index.js", + "scripts": { + "test": "node --test" + }, "dependencies": { "papaparse": "^5.5.3" } diff --git a/tests/configs.test.js b/tests/configs.test.js new file mode 100644 index 0000000..1b13821 --- /dev/null +++ b/tests/configs.test.js @@ -0,0 +1,144 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { validateDataset } from '../index.js'; + +async function loadConfig(file) { + const content = await readFile(new URL(`../configs/${file}`, import.meta.url), 'utf-8'); + return JSON.parse(content); +} + +test('basic-onboarding detecta múltiples errores de validación', async () => { + const definition = await loadConfig('basic-onboarding.json'); + const dataset = [ + { dni: '12345678', nombre: 'Juan Pérez', email: 'juan@example.com', telefono: '123456789' }, + { dni: '', nombre: 'Al', email: 'correo', telefono: '12' }, + ]; + + const result = await validateDataset(dataset, definition); + assert.equal(result.valid, false); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'dni')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'email')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'telefono')); +}); + +test('dependent-benefits integra reglas condicionales y resolvers externos', async () => { + const definition = await loadConfig('dependent-benefits.json'); + const context = { + resolvers: { + validateMinimumSalary: (salary, row) => { + const minimoPorTipo = { planta: 50000, contratista: 20000, practicante: 0 }; + const minimo = minimoPorTipo[row.tipo_empleado]; + return Number(salary) >= minimo; + }, + }, + }; + + const dataset = [ + { + tipo_empleado: 'planta', + fecha_ingreso: '2021-01-05', + salario: '60000', + bono: '5000', + beneficios_medicos: 'Plan A', + }, + { + tipo_empleado: 'planta', + fecha_ingreso: '', + salario: '30000', + bono: '40000', + beneficios_medicos: '', + }, + ]; + + const result = await validateDataset(dataset, definition, context); + assert.equal(result.valid, false); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'fecha_ingreso')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'salario')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'bono')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'beneficios_medicos')); +}); + +test('external-checks coordina múltiples resolvers por columna', async () => { + const definition = await loadConfig('external-checks.json'); + const context = { + resolvers: { + dniExisteEnCore: (dni) => ['12345678', '87654321'].includes(dni), + cuentaHabilitadaParaAlta: (cuenta, dni) => cuenta === 'CTA-1' && dni === '12345678', + montoDentroDeLimites: (monto, cuenta) => { + const limites = { 'CTA-1': 10000, 'CTA-2': 2000 }; + return Number(monto) <= (limites[cuenta] ?? 0); + }, + }, + }; + + const dataset = [ + { dni: '12345678', cuenta: 'CTA-1', monto: '5000' }, + { dni: '00000000', cuenta: 'CTA-2', monto: '5000' }, + ]; + + const result = await validateDataset(dataset, definition, context); + assert.equal(result.valid, false); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'dni')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'cuenta')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'monto')); +}); + +test('advanced-risk cubre reglas de filas y validaciones complejas', async () => { + const definition = await loadConfig('advanced-risk.json'); + const context = { + resolvers: { + validarReporteManual: (row) => Boolean(row?.reporte_manual?.adjunto), + }, + }; + + const dataset = [ + { + operacion_id: 'OP-000001', + estado: 'aprobada', + motivo_rechazo: '', + score: '80', + score_minimo_perfil: '70', + flag_sospechoso: 'NO', + comentarios: '', + monto_desembolsado: '150000', + fecha_aprobacion: '2024-02-01', + reporte_manual: { adjunto: true }, + }, + { + operacion_id: 'X-1', + estado: 'rechazada', + motivo_rechazo: '', + score: '40', + score_minimo_perfil: '60', + flag_sospechoso: 'SI', + comentarios: 'riesgo', + monto_desembolsado: '', + fecha_aprobacion: '', + reporte_manual: null, + }, + { + operacion_id: 'OP-000003', + estado: 'aprobada', + motivo_rechazo: '', + score: '55', + score_minimo_perfil: '40', + flag_sospechoso: 'SI', + comentarios: 'riesgo alto', + monto_desembolsado: '', + fecha_aprobacion: '2024/03/10', + reporte_manual: {}, + }, + ]; + + const result = await validateDataset(dataset, definition, context); + assert.equal(result.valid, false); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'operacion_id')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'motivo_rechazo')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'score')); + assert.ok(result.errors.some((err) => err.row === 2 && err.column === 'comentarios')); + assert.ok(result.errors.some((err) => err.row === 3 && err.column === 'monto_desembolsado')); + assert.ok(result.errors.some((err) => err.row === 3 && err.column === 'fecha_aprobacion')); + assert.ok(result.errors.some((err) => err.row === 3 && err.column === 'reporte_manual')); +}); + diff --git a/tests/parseCSV.test.js b/tests/parseCSV.test.js new file mode 100644 index 0000000..687f937 --- /dev/null +++ b/tests/parseCSV.test.js @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCSV } from '../utils/parseCSV.js'; + +const SAMPLE_CSV = ` +name,age,city +Alice,30,Buenos Aires + +Bob,25,Córdoba +`; + +test('parseCSV convierte un CSV con cabecera en un array de objetos', async () => { + const rows = await parseCSV(SAMPLE_CSV); + assert.deepStrictEqual(rows, [ + { name: 'Alice', age: '30', city: 'Buenos Aires' }, + { name: 'Bob', age: '25', city: 'Córdoba' }, + ]); +}); + diff --git a/tests/utils/evaluateCondition.test.js b/tests/utils/evaluateCondition.test.js new file mode 100644 index 0000000..f34feb7 --- /dev/null +++ b/tests/utils/evaluateCondition.test.js @@ -0,0 +1,75 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { evaluateCondition } from '../../utils/evaluateCondition.js'; + +const row = { + estado: 'activo', + score: '75', + categoria: 'gold', + comentarios: 'Cliente con buen comportamiento', +}; + +const context = { + resolvers: { + tieneSaldoSuficiente: (value) => Number(value) > 0, + }, +}; + +const evaluationContext = { + value: row.estado, + row, + context, + rule: {}, +}; + +test('evaluateCondition soporta combinaciones complejas con all y any', async () => { + const condition = { + all: [ + { field: 'estado', equals: 'activo' }, + { + any: [ + { field: 'categoria', in: ['platinum', 'gold'] }, + { field: 'score', gte: 80 }, + ], + }, + ], + }; + + const result = await evaluateCondition(condition, evaluationContext); + assert.equal(result, true); +}); + +test('evaluateCondition puede negar condiciones y validar por regex', async () => { + const condition = { + all: [ + { not: { field: 'estado', equals: 'bloqueado' } }, + { field: 'comentarios', regex: 'comportamiento', flags: 'i' }, + ], + }; + const result = await evaluateCondition(condition, evaluationContext); + assert.equal(result, true); +}); + +test('evaluateCondition delega en resolvers externos cuando se especifica', async () => { + const condition = { + external: 'tieneSaldoSuficiente', + args: ['$row.score'], + }; + + const result = await evaluateCondition(condition, evaluationContext); + assert.equal(result, true); +}); + +test('evaluateCondition soporta verificaciones de presencia, longitud y comparaciones numéricas', async () => { + const condition = { + all: [ + { field: 'comentarios', present: true }, + { field: 'comentarios', minLength: 5, maxLength: 80 }, + { field: 'score', gte: 70 }, + ], + }; + + const result = await evaluateCondition(condition, evaluationContext); + assert.equal(result, true); +}); + diff --git a/tests/utils/tokenResolver.test.js b/tests/utils/tokenResolver.test.js new file mode 100644 index 0000000..1727bd5 --- /dev/null +++ b/tests/utils/tokenResolver.test.js @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveToken, resolveTokens } from '../../utils/tokenResolver.js'; + +const evaluationContext = { + value: 'ABC123', + row: { + dni: '12345678', + datos: { estado: 'activo' }, + }, + context: { tenant: 'ar', metadata: { source: 'test' } }, + rule: { + constants: { + thresholds: { min: 10 }, + }, + }, +}; + +test('resolveToken permite acceder al valor actual y al registro completo', () => { + assert.equal(resolveToken('$value', evaluationContext), 'ABC123'); + assert.equal(resolveToken('$row.dni', evaluationContext), '12345678'); + assert.deepEqual(resolveToken('$row.datos', evaluationContext), { estado: 'activo' }); +}); + +test('resolveToken permite leer del contexto y constantes de la regla', () => { + assert.equal(resolveToken('$context.tenant', evaluationContext), 'ar'); + assert.equal(resolveToken('$const.thresholds.min', evaluationContext), 10); +}); + +test('resolveTokens procesa arreglos mixtos', () => { + const tokens = resolveTokens(['$value', '$row.datos.estado', 'literal'], evaluationContext); + assert.deepEqual(tokens, ['ABC123', 'activo', 'literal']); +}); + diff --git a/tests/validateDataset.test.js b/tests/validateDataset.test.js new file mode 100644 index 0000000..0445c29 --- /dev/null +++ b/tests/validateDataset.test.js @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateDataset } from '../index.js'; + +const definition = { + columns: { + id: { + rules: ['required'], + }, + estado: { + rules: ['required'], + }, + comentario: { + rules: [ + { + type: 'string', + minLength: 5, + message: 'El comentario es muy corto', + allowEmpty: false, + onlyIf: { field: 'estado', equals: 'rechazado' }, + }, + ], + }, + }, + rowValidations: [ + { + when: { field: 'estado', equals: 'aprobado' }, + assertions: [ + { + column: 'aprobador', + rule: { type: 'required', message: 'Las aprobaciones requieren aprobador' }, + }, + ], + }, + ], +}; + +const dataset = [ + { id: '1', estado: 'aprobado', comentario: 'ok', aprobador: 'Ana' }, + { id: '', estado: 'rechazado', comentario: '', aprobador: '' }, + { id: '3', estado: 'aprobado', comentario: 'listo', aprobador: '' }, +]; + +test('validateDataset retorna errores detallados por fila y columna', async () => { + const result = await validateDataset(dataset, definition); + assert.equal(result.valid, false); + assert.equal(result.errors.length, 3); + + const missingId = result.errors.find((err) => err.column === 'id' && err.row === 2); + assert.deepEqual(missingId, { + row: 2, + column: 'id', + rule: 'required', + message: 'Campo obligatorio', + }); + + const commentError = result.errors.find((err) => err.column === 'comentario' && err.row === 2); + assert.deepEqual(commentError, { + row: 2, + column: 'comentario', + rule: 'string', + message: 'El comentario es muy corto', + }); + + const approverError = result.errors.find((err) => err.column === 'aprobador' && err.row === 3); + assert.deepEqual(approverError, { + row: 3, + column: 'aprobador', + rule: 'required', + message: 'Las aprobaciones requieren aprobador', + }); +}); + diff --git a/tests/validators.test.js b/tests/validators.test.js new file mode 100644 index 0000000..fd23f3d --- /dev/null +++ b/tests/validators.test.js @@ -0,0 +1,101 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import required from '../validators/required.js'; +import number from '../validators/number.js'; +import stringValidator from '../validators/string.js'; +import inRule from '../validators/in.js'; +import compare from '../validators/compare.js'; +import requiredIf from '../validators/requiredIf.js'; +import custom from '../validators/custom.js'; + +const baseRow = { segmento: 'premium', referencia: 'ABC' }; + +test('required detecta valores vacíos', () => { + const result = required('', baseRow, { message: 'obligatorio' }); + assert.equal(result, 'obligatorio'); + assert.equal(required('valor', baseRow, {}), true); +}); + +test('number valida enteros y rangos', () => { + assert.equal(number('', baseRow, {}), true); + assert.equal(number('abc', baseRow, {}), 'Debe ser numérico'); + assert.equal(number('10', baseRow, { integer: true }), true); + assert.equal(number('10.5', baseRow, { integer: true }), 'Debe ser un número entero'); + assert.equal(number('4', baseRow, { min: 5 }), 'Debe ser ≥ 5'); + assert.equal(number('12', baseRow, { max: 10 }), 'Debe ser ≤ 10'); +}); + +test('string aplica trimming, longitudes y expresiones regulares', () => { + assert.equal(stringValidator('', baseRow, { allowEmpty: false }), 'El texto no puede estar vacío'); + assert.equal(stringValidator(' ab ', baseRow, { minLength: 2 }), true); + assert.equal(stringValidator('abc', baseRow, { minLength: 2, maxLength: 5 }), true); + assert.equal(stringValidator('abcdef', baseRow, { maxLength: 5 }), 'Debe tener como máximo 5 caracteres'); + assert.equal(stringValidator('1234', baseRow, { length: 4 }), true); + assert.equal(stringValidator('hola', baseRow, { pattern: '^adios$' }), 'Formato inválido'); +}); + +test('in valida contra un conjunto permitido', () => { + assert.equal(inRule('premium', baseRow, { values: ['premium', 'standard'] }), true); + assert.equal( + inRule('gold', baseRow, { values: ['premium', 'standard'], message: 'Fuera de catálogo' }), + 'Fuera de catálogo', + ); +}); + +test('compare soporta múltiples operadores y tokens', () => { + const row = { edad: 30, edad_minima: 21, flags: ['a', 'b'] }; + assert.equal(compare('30', row, { type: 'compare', operator: 'gte', right: '$row.edad_minima' }), true); + assert.equal( + compare('20', row, { type: 'compare', operator: 'gte', right: '$row.edad_minima' }), + 'La comparación gte no se cumple', + ); + assert.equal(compare('a', row, { type: 'compare', operator: 'in', right: '$row.flags' }), true); +}); + +test('requiredIf hace obligatorio un campo según otra columna', async () => { + const row = { canal: 'online', codigo: '' }; + const rule = { + type: 'requiredIf', + when: { field: 'canal', equals: 'online' }, + message: 'Necesitamos el código para canal online', + }; + const result = await requiredIf(row.codigo, row, rule, {}); + assert.equal(result, 'Necesitamos el código para canal online'); + + const ok = await requiredIf('valor', row, rule, {}); + assert.equal(ok, true); +}); + +test('custom ejecuta funciones inline y resolvers externos', async () => { + const inline = await custom('abc', baseRow, { + type: 'custom', + validate: (value) => (value === 'abc' ? true : 'falló'), + }); + assert.equal(inline, true); + + const context = { + resolvers: { + validarSegmento: (value, row, ctx, rule, args) => { + assert.equal(args[0], value); + assert.equal(args[1], row.segmento); + return { valid: value === 'abc', meta: { recibido: args } }; + }, + }, + }; + + const ok = await custom('abc', baseRow, { + type: 'custom', + resolver: 'validarSegmento', + args: ['$value', '$row.segmento'], + }, context); + assert.equal(ok, true); + + const fail = await custom('xyz', baseRow, { + type: 'custom', + resolver: 'validarSegmento', + args: ['$value', '$row.segmento'], + message: 'Segmento inválido', + }, context); + assert.deepEqual(fail, { message: 'Segmento inválido', meta: { recibido: ['xyz', 'premium'] } }); +}); + diff --git a/utils/evaluateCondition.js b/utils/evaluateCondition.js new file mode 100644 index 0000000..6dc7ee1 --- /dev/null +++ b/utils/evaluateCondition.js @@ -0,0 +1,145 @@ +import { resolveToken, resolveTokens } from './tokenResolver.js'; + +function coerceToNumber(input) { + if (input == null || input === '') return undefined; + const n = Number(input); + return Number.isNaN(n) ? undefined : n; +} + +function ensureArray(value) { + if (Array.isArray(value)) return value; + if (value == null) return []; + return [value]; +} + +async function runExternal(condition, evaluationContext) { + const { context } = evaluationContext; + const resolverName = condition.external || condition.resolver; + const argsTokens = condition.args ?? []; + const fn = context?.resolvers?.[resolverName]; + if (typeof fn !== 'function') { + throw new Error(`Resolver externo "${resolverName}" no encontrado en el contexto`); + } + const args = argsTokens.length + ? argsTokens.map((arg) => resolveToken(arg, evaluationContext)) + : [evaluationContext.value, evaluationContext.row, context, evaluationContext.rule]; + const result = await fn(...args); + if (typeof result === 'object' && result !== null && 'valid' in result) { + return Boolean(result.valid); + } + return Boolean(result); +} + +export async function evaluateCondition(condition, evaluationContext) { + if (!condition) return true; + + if (Array.isArray(condition)) { + const evaluations = await Promise.all( + condition.map((inner) => evaluateCondition(inner, evaluationContext)) + ); + return evaluations.every(Boolean); + } + + if (condition.all) { + const evaluations = await Promise.all( + condition.all.map((inner) => evaluateCondition(inner, evaluationContext)) + ); + return evaluations.every(Boolean); + } + + if (condition.any) { + const evaluations = await Promise.all( + condition.any.map((inner) => evaluateCondition(inner, evaluationContext)) + ); + return evaluations.some(Boolean); + } + + if (condition.not) { + const result = await evaluateCondition(condition.not, evaluationContext); + return !result; + } + + if (condition.external || condition.resolver) { + return runExternal(condition, evaluationContext); + } + + const leftToken = condition.target ?? condition.left ?? (condition.field ? `$row.${condition.field}` : '$value'); + const leftValue = resolveToken(leftToken, evaluationContext); + + if (condition.present !== undefined) { + const shouldBePresent = Boolean(condition.present); + const isPresent = leftValue !== undefined && leftValue !== null && leftValue !== ''; + return shouldBePresent ? isPresent : !isPresent; + } + + if (condition.empty !== undefined) { + const shouldBeEmpty = Boolean(condition.empty); + const isEmpty = leftValue === undefined || leftValue === null || leftValue === ''; + return shouldBeEmpty ? isEmpty : !isEmpty; + } + + if (condition.lengthEquals != null || condition.minLength != null || condition.maxLength != null) { + const valueAsString = leftValue != null ? String(leftValue) : ''; + if (condition.lengthEquals != null && valueAsString.length !== condition.lengthEquals) { + return false; + } + if (condition.minLength != null && valueAsString.length < condition.minLength) { + return false; + } + if (condition.maxLength != null && valueAsString.length > condition.maxLength) { + return false; + } + } + + if (condition.regex) { + const flags = condition.flags ?? ''; + const regex = new RegExp(condition.regex, flags); + return typeof leftValue === 'string' && regex.test(leftValue); + } + + if (condition.in) { + const values = ensureArray(resolveTokens(condition.in, evaluationContext)); + return values.includes(leftValue); + } + + if (condition.notIn) { + const values = ensureArray(resolveTokens(condition.notIn, evaluationContext)); + return !values.includes(leftValue); + } + + if (condition.equals !== undefined || condition.equalsFrom !== undefined) { + const rightValue = condition.equalsFrom + ? resolveToken(condition.equalsFrom, evaluationContext) + : condition.equals; + return leftValue === rightValue; + } + + if (condition.notEquals !== undefined || condition.notEqualsFrom !== undefined) { + const rightValue = condition.notEqualsFrom + ? resolveToken(condition.notEqualsFrom, evaluationContext) + : condition.notEquals; + return leftValue !== rightValue; + } + + if (condition.gt != null || condition.gte != null || condition.lt != null || condition.lte != null) { + const numericLeft = coerceToNumber(leftValue); + if (numericLeft === undefined) return false; + if (condition.gt != null && !(numericLeft > Number(condition.gt))) return false; + if (condition.gte != null && !(numericLeft >= Number(condition.gte))) return false; + if (condition.lt != null && !(numericLeft < Number(condition.lt))) return false; + if (condition.lte != null && !(numericLeft <= Number(condition.lte))) return false; + return true; + } + + if (condition.truthy !== undefined) { + const isTruthy = Boolean(leftValue); + return condition.truthy ? isTruthy : !isTruthy; + } + + if (condition.falsy !== undefined) { + const isFalsy = !leftValue; + return condition.falsy ? isFalsy : !isFalsy; + } + + return Boolean(leftValue); +} diff --git a/utils/tokenResolver.js b/utils/tokenResolver.js new file mode 100644 index 0000000..78b91d6 --- /dev/null +++ b/utils/tokenResolver.js @@ -0,0 +1,61 @@ +const PATH_SEPARATOR = '.'; + +function accessPath(source, path) { + if (source == null) return undefined; + if (!path) return source; + return path.split(PATH_SEPARATOR).reduce((acc, segment) => { + if (acc == null) return undefined; + if (segment === '*') return acc; // wildcard placeholder, no traversal + return acc[segment]; + }, source); +} + +export function resolveToken(token, evaluationContext) { + const { value, row, context } = evaluationContext; + + if (Array.isArray(token)) { + return token.map((item) => resolveToken(item, evaluationContext)); + } + + if (typeof token !== 'string') { + return token; + } + + if (token === '$value') { + return value; + } + + if (token === '$row') { + return row; + } + + if (token === '$context') { + return context; + } + + if (token.startsWith('$row.')) { + const path = token.slice(5); + return accessPath(row, path); + } + + if (token.startsWith('$context.')) { + const path = token.slice(9); + return accessPath(context, path); + } + + if (token.startsWith('$const.')) { + // Allows referencing arbitrary constants defined inside the rule. + const { constants } = evaluationContext.rule ?? {}; + const path = token.slice(7); + return accessPath(constants, path); + } + + return token; +} + +export function resolveTokens(value, evaluationContext) { + if (Array.isArray(value)) { + return value.map((item) => resolveToken(item, evaluationContext)); + } + return resolveToken(value, evaluationContext); +} diff --git a/validators/compare.js b/validators/compare.js new file mode 100644 index 0000000..7d03500 --- /dev/null +++ b/validators/compare.js @@ -0,0 +1,45 @@ +import { resolveToken } from '../utils/tokenResolver.js'; + +const OPERATORS = { + eq: (a, b) => a === b, + equals: (a, b) => a === b, + ne: (a, b) => a !== b, + notEquals: (a, b) => a !== b, + gt: (a, b) => Number(a) > Number(b), + gte: (a, b) => Number(a) >= Number(b), + lt: (a, b) => Number(a) < Number(b), + lte: (a, b) => Number(a) <= Number(b), + in: (a, b) => Array.isArray(b) && b.includes(a), + notIn: (a, b) => Array.isArray(b) && !b.includes(a), + contains: (a, b) => typeof a === 'string' && typeof b === 'string' && a.includes(b), + startsWith: (a, b) => typeof a === 'string' && typeof b === 'string' && a.startsWith(b), + endsWith: (a, b) => typeof a === 'string' && typeof b === 'string' && a.endsWith(b), +}; + +export default function compare(value, row, rule, context) { + const evaluationContext = { value, row, context, rule }; + const leftToken = rule.left ?? rule.target ?? '$value'; + const rightToken = + rule.right ?? + rule.compareTo ?? + rule.rightFrom ?? + rule.value ?? + rule.expected ?? + rule.equals; + const operatorKey = rule.operator ?? 'eq'; + const operator = OPERATORS[operatorKey]; + + if (!operator) { + throw new Error(`Operador de comparación "${operatorKey}" no soportado`); + } + + const left = resolveToken(leftToken, evaluationContext); + const right = resolveToken(rightToken, evaluationContext); + + if (rule.skipIfEmpty && (left === undefined || left === null || left === '')) { + return true; + } + + const isValid = operator(left, right); + return isValid ? true : rule.message || `La comparación ${operatorKey} no se cumple`; +} diff --git a/validators/custom.js b/validators/custom.js index 08231a7..08b8288 100644 --- a/validators/custom.js +++ b/validators/custom.js @@ -1,3 +1,50 @@ -export default async function custom(value, row, rule, context) { - return await rule.validate(value, row, context); +import { resolveTokens } from '../utils/tokenResolver.js'; + +function normaliseExternalResult(result, rule) { + if (result === true) return { valid: true }; + if (result === false) { + return { valid: false, message: rule.message || 'Validación personalizada no superada' }; + } + if (typeof result === 'string') { + return { valid: false, message: result }; + } + if (result && typeof result === 'object') { + const { valid, message, meta } = result; + if (valid === undefined) { + return { + valid: false, + message: message || rule.message || 'Validación personalizada no superada', + meta, + }; + } + return { + valid: Boolean(valid), + message: message || rule.message || 'Validación personalizada no superada', + meta, + }; + } + return { valid: true }; +} + +export default async function custom(value, row, rule, context = {}) { + if (typeof rule.validate === 'function') { + return await rule.validate(value, row, context); + } + + const resolverName = rule.resolver || rule.external; + if (!resolverName) { + throw new Error('Las reglas tipo "custom" requieren una función validate o un resolver'); + } + + const resolvers = context.resolvers ?? {}; + const resolver = resolvers[resolverName]; + if (typeof resolver !== 'function') { + throw new Error(`Resolver "${resolverName}" no encontrado en el contexto`); + } + + const evaluationContext = { value, row, context, rule }; + const resolvedArgs = resolveTokens(rule.args ?? ['$value'], evaluationContext); + const result = await resolver(value, row, context, rule, resolvedArgs); + const normalised = normaliseExternalResult(result, rule); + return normalised.valid ? true : { message: normalised.message, meta: normalised.meta }; } diff --git a/validators/in.js b/validators/in.js index 05da467..aefc963 100644 --- a/validators/in.js +++ b/validators/in.js @@ -1,6 +1,10 @@ -export default function inRule(value, row, rule) { - if (value === undefined || value === '') return true; - return rule.values.includes(value) +import { resolveTokens } from '../utils/tokenResolver.js'; + +export default function inRule(value, row, rule, context) { + if (value === undefined || value === null || value === '') return true; + const evaluationContext = { value, row, context, rule }; + const allowedValues = resolveTokens(rule.values ?? [], evaluationContext); + return allowedValues.includes(value) ? true - : `Debe ser uno de: ${rule.values.join(', ')}`; + : rule.message || `Debe ser uno de: ${allowedValues.join(', ')}`; } diff --git a/validators/index.js b/validators/index.js index d6a5588..cfef5ec 100644 --- a/validators/index.js +++ b/validators/index.js @@ -3,6 +3,8 @@ import number from './number.js'; import inRule from './in.js'; import requiredIf from './requiredIf.js'; import custom from './custom.js'; +import stringValidator from './string.js'; +import compare from './compare.js'; export default { required, @@ -10,4 +12,6 @@ export default { in: inRule, requiredIf, custom, + string: stringValidator, + compare, }; diff --git a/validators/number.js b/validators/number.js index 649fbd1..d2040a7 100644 --- a/validators/number.js +++ b/validators/number.js @@ -1,8 +1,17 @@ export default function number(value, row, rule) { - if (value === undefined || value === '') return true; + if (value === undefined || value === null || value === '') return true; const n = Number(value); - if (isNaN(n)) return 'Debe ser numérico'; - if (rule.min != null && n < rule.min) return `Debe ser ≥ ${rule.min}`; - if (rule.max != null && n > rule.max) return `Debe ser ≤ ${rule.max}`; + if (Number.isNaN(n)) { + return rule.message || 'Debe ser numérico'; + } + if (rule.integer && !Number.isInteger(n)) { + return rule.message || 'Debe ser un número entero'; + } + if (rule.min != null && n < rule.min) { + return rule.message || `Debe ser ≥ ${rule.min}`; + } + if (rule.max != null && n > rule.max) { + return rule.message || `Debe ser ≤ ${rule.max}`; + } return true; } diff --git a/validators/required.js b/validators/required.js index db4dd84..4b94f0d 100644 --- a/validators/required.js +++ b/validators/required.js @@ -1,3 +1,4 @@ -export default function required(value) { - return value !== undefined && value !== '' ? true : 'Campo obligatorio'; +export default function required(value, row, rule) { + const isPresent = value !== undefined && value !== null && value !== ''; + return isPresent ? true : rule.message || 'Campo obligatorio'; } diff --git a/validators/requiredIf.js b/validators/requiredIf.js index 83ee985..2701fa0 100644 --- a/validators/requiredIf.js +++ b/validators/requiredIf.js @@ -1,8 +1,33 @@ -export default function requiredIf(value, row, rule) { - const depValue = row[rule.dependsOn]; - const shouldBeRequired = rule.condition(depValue); - if (shouldBeRequired && (value === undefined || value === '')) { - return rule.message || `Campo requerido si ${rule.dependsOn} cumple condición`; +import { evaluateCondition } from '../utils/evaluateCondition.js'; + +export default async function requiredIf(value, row, rule, context) { + let shouldBeRequired = false; + const evaluationContext = { value, row, context, rule }; + + if (typeof rule.condition === 'function') { + const depValue = rule.dependsOn ? row[rule.dependsOn] : value; + shouldBeRequired = await rule.condition(depValue, row, context); + } else if (rule.when) { + shouldBeRequired = await evaluateCondition(rule.when, evaluationContext); + } else if (rule.dependsOn) { + const dependsValue = row[rule.dependsOn]; + if (rule.equals !== undefined) { + shouldBeRequired = dependsValue === rule.equals; + } else if (rule.in) { + shouldBeRequired = rule.in.includes(dependsValue); + } else { + shouldBeRequired = Boolean(dependsValue); + } } + + if (shouldBeRequired && (value === undefined || value === null || value === '')) { + return ( + rule.message || + (rule.dependsOn + ? `Campo requerido si ${rule.dependsOn} cumple la condición` + : 'Campo requerido por la condición configurada') + ); + } + return true; } diff --git a/validators/string.js b/validators/string.js new file mode 100644 index 0000000..cc67963 --- /dev/null +++ b/validators/string.js @@ -0,0 +1,36 @@ +export default function stringValidator(value, row, rule) { + if (value === undefined || value === null || value === '') { + return rule.allowEmpty !== false ? true : rule.message || 'El texto no puede estar vacío'; + } + + const str = rule.trim === false ? String(value) : String(value).trim(); + + if (rule.minLength != null && str.length < rule.minLength) { + return rule.message || `Debe tener al menos ${rule.minLength} caracteres`; + } + + if (rule.maxLength != null && str.length > rule.maxLength) { + return rule.message || `Debe tener como máximo ${rule.maxLength} caracteres`; + } + + if (rule.length != null && str.length !== rule.length) { + return rule.message || `Debe tener exactamente ${rule.length} caracteres`; + } + + if (rule.pattern) { + const regex = new RegExp(rule.pattern, rule.flags ?? ''); + if (!regex.test(str)) { + return rule.message || 'Formato inválido'; + } + } + + if (rule.uppercase === true && str !== str.toUpperCase()) { + return rule.message || 'Debe estar en mayúsculas'; + } + + if (rule.lowercase === true && str !== str.toLowerCase()) { + return rule.message || 'Debe estar en minúsculas'; + } + + return true; +}