// Tests Node puros (sin navegador, sin DB) del validador server-side de submit. // Ejecutar: node --test lib/submit-validation.test.mts import { test, describe } from 'node:test' import assert from 'node:assert/strict' import { buildFormSchema } from './form-engine.ts' import { validateSubmission } from './submit-validation.ts' import type { ConditionalRule, Question } from './form-engine.types.ts' import type { SubmitAnswerEntry } from './submit-validation.ts' function q(partial: Partial & Pick): Question { return { id: partial.code, is_sensitive: false, required: false, hint: null, options: null, max_select: null, conditional_rules: null, instance_generator: false, instance_of: null, ...partial, } } const r_show_si_3_1: ConditionalRule[] = [ { if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }, ] const VINCULOS = ['Hijo menor con discapacidad', 'Familiar mayor con discapacidad', 'Otro / Especificar'] function buildFixtureSchema() { const rows: Question[] = [ q({ code: 'S2', type: 'section', prompt: 'Datos generales', position: 20 }), q({ code: '2.5', type: 'single_choice', prompt: 'Género', position: 25, required: true, options: [{ value: 'Femenino', label: 'Femenino' }, { value: 'Masculino', label: 'Masculino' }], }), q({ code: 'S3', type: 'section', prompt: 'Prevalencia', position: 30 }), q({ code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31, options: [{ value: 'Sí', label: 'Sí' }, { value: 'No', label: 'No' }], }), q({ code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_3_1, options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }], }), q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }), q({ code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41, instance_generator: true, options: VINCULOS.map((v) => ({ value: v, label: v })), }), q({ code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1', max_select: 2, options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }], }), ] return buildFormSchema(rows) } // ── Happy path: 2 familiares ────────────────────────────────────────────── test('happy path: submit válido con 2 familiares — payload completo con instance_id', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '3.1', instanceId: 'default', value: 'No' }, // oculta 3.2 — no se manda { code: '4.1', instanceId: 'default', value: [VINCULOS[0], VINCULOS[1]] }, { code: '4.2', instanceId: 'familiar_1', value: ['Visual'] }, { code: '4.2', instanceId: 'familiar_2', value: ['Auditiva'] }, ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, true) if (!result.ok) return // ── Dato concreto pedido en el reporte: filas reales con instance_id ── console.log('\nPayload validado → filas listas para la RPC (questionId/instanceId/value):') console.log(JSON.stringify(result.rows, null, 2)) assert.equal(result.rows.length, 5) const f1 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_1') const f2 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_2') assert.deepEqual(f1?.value, ['Visual']) assert.deepEqual(f2?.value, ['Auditiva']) }) describe('rechazos — las 7 validaciones', () => { test('rechaza respuesta a una pregunta que el flujo nunca debió mostrar (3.2 con 3.1=No)', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '3.1', instanceId: 'default', value: 'No' }, { code: '3.2', instanceId: 'default', value: ['Visual'] }, // 3.1='No' la oculta ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.startsWith('question_should_be_hidden:3.2'))) }) test('rechaza valor fuera de las opciones válidas', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '3.1', instanceId: 'default', value: 'Tal vez' }, // no es 'Sí' ni 'No' ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.includes('value_not_in_options') && e.includes('3.1'))) }) test('rechaza max_select excedido (4.2 tiene max_select=2)', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '4.1', instanceId: 'default', value: [VINCULOS[0]] }, { code: '4.2', instanceId: 'familiar_1', value: ['Visual', 'Auditiva', 'Visual'] }, // 3 > max_select(2) ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.includes('exceeds_max_select'))) }) test('rechaza instancia inventada (familiar que 4.1 no generó)', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '4.1', instanceId: 'default', value: [VINCULOS[0]] }, // genera solo familiar_1 { code: '4.2', instanceId: 'familiar_2', value: ['Visual'] }, // familiar_2 NO existe ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.startsWith('invalid_instance:4.2:familiar_2'))) }) test('rechaza instance_id en una pregunta que no es de instancia', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'familiar_1', value: 'Femenino' }, // 2.5 no es instance_of nada ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.startsWith('unexpected_instance_id:2.5'))) }) test('rechaza sin consentimiento operativo', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [{ code: '2.5', instanceId: 'default', value: 'Femenino' }] const result = validateSubmission(schema, entries, false) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.includes('consent_operativo_required')) }) test('rechaza pregunta requerida visible sin responder', () => { const schema = buildFixtureSchema() // 2.5 es required=true y no se manda const entries: SubmitAnswerEntry[] = [{ code: '3.1', instanceId: 'default', value: 'No' }] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.startsWith('required_missing:2.5'))) }) test('rechaza pregunta que no existe en el esquema (code inventado)', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '99.9', instanceId: 'default', value: 'inyectado' }, ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.startsWith('unknown_question:99.9'))) }) test('rechaza respuesta a una fila type=section', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: 'S4', instanceId: 'default', value: 'algo' }, ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.startsWith('section_is_not_answerable:S4'))) }) test('rechaza valor de tipo incorrecto (string donde se espera array)', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '4.1', instanceId: 'default', value: 'Hijo menor con discapacidad' as unknown as string[] }, ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.some((e) => e.includes('expected_array_value'))) }) test('reporta TODOS los errores encontrados, no solo el primero', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '99.9', instanceId: 'default', value: 'inyectado' }, { code: 'S4', instanceId: 'default', value: 'algo' }, ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, false) if (result.ok) return assert.ok(result.errors.length >= 2, `esperaba >=2 errores, hubo ${result.errors.length}`) }) }) describe('valores vacíos', () => { test('una entrada con valor vacío no se valida ni se persiste, pero no bloquea el submit', () => { const schema = buildFixtureSchema() const entries: SubmitAnswerEntry[] = [ { code: '2.5', instanceId: 'default', value: 'Femenino' }, { code: '3.1', instanceId: 'default', value: '' }, // vacío — se ignora, no se evalúa su visibilidad ] const result = validateSubmission(schema, entries, true) assert.equal(result.ok, true) if (!result.ok) return assert.equal(result.rows.some((r) => r.questionId === '3.1'), false) }) })