01 · Resumen ejecutivo

21 archivos de test que blindan motor + catálogos

Esta suite consolida dos documentos de QA técnico que se publicaron juntos pero deben leerse como uno solo: FARO-TEST-002 (reglas, motor, integración) y FARO-TEST-002.1 (alineación con los catálogos canónicos FARO-SQL-004/005/006). Juntos forman 21 archivos .test.ts, 12 del bloque base y 8 del patch canónico, más 1 archivo de consistencia legacy.

FARO maneja una cadena ejecutable que debe permanecer alineada en todo momento:

rule_definitionstension_definitionsaction_definitionsevidence_definitionsmotortensionsactionsevidence requirementsreportsscore

Si una sola unión se rompe en silencio, el producto falla en demo sin avisar. Los tests no son ceremonia académica: son la diferencia entre poder decir “corre, valida, falla cuando debe fallar y genera el resultado esperado” y tener que decir “creemos que funciona” frente a un CTO. Una vende sistema. La otra vende fe.

El framework elegido es Vitest sobre PostgreSQL 15 con contexto RLS por test (set_config('app.company_id', ...) + BEGIN/ROLLBACK por defecto). Las fixtures viven en src/fixtures/, los helpers de aserción y SQL en src/helpers/, y los tests en tests/01..20. La base QA es Empresa Demo Cuyo S.A. (company_id = 10000000-0000-0000-0000-000000000001) con la trayectoria Score 74 → 66 como expected fijo en fixtures.

La suite corre en CI sobre PostgreSQL 15 de servicio (GitHub Actions). Cada pull request que toque rules/, src/, tests/, migrations/ o seeds/ bloquea el merge si cualquiera de los 21 archivos falla. El bloque canónico (tests 13-20) es el que asegura que el motor no vuelva a tener un ACTION_CATALOG hardcodeado escondido en algún servicio.

02 · Advertencia histórica resuelta

El desalineamiento TNS que motivó el catálogo canónico

FARO-TEST-002 (v1.0) abrió con una advertencia crítica que hoy ya tiene resolución implementada. La dejamos documentada como contexto histórico, no como acción pendiente.

Alerta original (TEST-002 §2). Se detectó una posible diferencia de codificación entre FARO-SQL-003 (seed Empresa Demo) y FARO-CFG-001 (reglas YAML). El seed usaba TNS-002 = Venta sin caja mientras que las reglas YAML usaban TNS-004 = Venta sin caja. Idéntica desalineación en TNS-003/004/005. Sin corrección, el motor podría crear una tensión con un código que la UI o el reporte esperan distinto: el clásico “funciona, pero no aparece”.

Resolución cerrada en pack actual. La advertencia se resolvió con tres piezas que ya están publicadas: catalogo-tensiones-mvp.html (FARO-SQL-004 + V025/V026 con las 30 fichas canónicas), catalogo-acciones-mvp.html (FARO-SQL-005) y catalogo-evidencias-mvp.html (FARO-SQL-006). El patch FARO-SQL-003.1 corrigió el seed demo (TNS-002 → TNS-004 venta-caja, TNS-003 → TNS-006 stock crítico, TNS-004 → TNS-007 inmovilizado, TNS-005 → TNS-009/010 acciones). Hoy el set canónico de demo es TNS-001 / TNS-004 / TNS-006 / TNS-007 / TNS-009 / TNS-010.

El test que detectaría una regresión hacia este desalineamiento es tests/12_catalog_consistency.test.ts (TEST-002 base) reforzado por tests/13_catalog_rules_alignment.test.ts (TEST-002.1 canónico). Ambos están descritos en las secciones siguientes. Si alguno empieza a fallar, el primer lugar a mirar es si volvió a haber dos códigos para la misma idea.

Frase de gobierno. “El cementerio de los MVP está lleno de funcionalidades que funcionan pero no aparecen. La fe sirve para misa. Para un CTO, tests.”

03 · 9 niveles de prueba

Cobertura del bloque base TEST-002

FARO-TEST-002 define nueve niveles oficiales de prueba que cubren desde funciones puras hasta CI. Cada nivel responde a una pregunta concreta y mapea a uno o varios archivos tests/01..12.

Nivel 01 · Unit

Funciones puras

Condiciones, severidad, score impact, confianza. Sin DB, sin mocks complejos. Fallar acá es bug evidente.

Vitest
Nivel 02 · Parser

YAML válido / inválido

Rechaza operadores no soportados, KPIs desconocidos, ausencia de rule_code, evidencia vacía.

Vitest + fixtures
Nivel 03 · Contract

Schema + catálogo

Cada regla cumple el contrato JSON Schema y referencia códigos del catálogo canónico (TNS/ACT/EVD).

Vitest + Ajv
Nivel 04 · Engine

Motor contra KPIs mock

Evalúa reglas usando snapshots JSON, sin DB. Valida triggered/severidad/priority/scoreImpact.

Vitest
Nivel 05 · Integration

Motor escribe en PostgreSQL

Corre runEvaluation contra DB de test, valida que crea tensions y actions reales.

Vitest + DB test
Nivel 06 · RLS

Tenant A no ve Tenant B

Aislamiento multiempresa. Sin contexto: 0 filas. Con contexto A: solo datos de A. Errores acá son críticos.

SQL + Vitest
Nivel 07 · Idempotency

No duplica tensiones / acciones

Corre el motor dos veces sobre el mismo período. Segunda corrida: 0 creaciones, ≥1 actualización.

DB test
Nivel 08 · Regression

Dataset demo → expected JSON

Empresa Demo Cuyo dispara las tensiones esperadas con severidad y priority score sobre el umbral.

Expected JSON
Nivel 09 · CI

Todo corre en pull request

GitHub Actions con PostgreSQL service, migrations + seeds + tests. Bloqueante para merge a main.

GitHub Actions

Los archivos tests/01..12 mapean a estos niveles uno a uno (schema, parser, condiciones, confianza, severidad, motor unit, motor integration, idempotencia, acciones + evidencia, RLS, regresión demo, catalog consistency legacy).

04 · 8 tests canónicos

Patch TEST-002.1 · tests/13..20

FARO-TEST-002.1 agrega ocho archivos que validan que la cadena completa de catálogos canónicos esté alineada. Cada uno asume que ya existen FARO-SQL-004/005/006 aplicados y el motor enriquecido por FARO-ENG-003.1.

Test 13 CANÓNICO

Reglas → tensiones

tests/13_catalog_rules_alignment.test.ts

Toda regla activa apunta a una tensión canónica existente. RULE-TNS-NNN coincide numéricamente con TNS-NNN. Las 6 reglas demo (001/004/006/007/009/010) están presentes y activas.

Test 14 CANÓNICO

Tensiones → acciones

tests/14_catalog_tension_action_alignment.test.ts

Exactamente 30 tensiones activas. Toda recommended_actions existe en action_definitions. Ninguna tensión activa tiene array vacío. Las 6 tensiones demo recomiendan las acciones esperadas.

Test 15 CANÓNICO

Acciones → evidencias

tests/15_catalog_action_evidence_alignment.test.ts

30 acciones y 12 evidencias activas. Toda evidence_required_codes existe en catálogo. Cada acción activa tiene closure_criteria no vacío y default_sla_days definido.

Test 16 CANÓNICO

Motor → payload canónico

tests/16_engine_canonical_payload.test.ts

Toda tensión creada o actualizada por el motor lleva payload.canonical = true, catalog_source, tension_definition_id, business_question, score_dimension y evidence_requirements. Acciones idem.

Test 17 CANÓNICO

Demo → catálogos

tests/17_demo_canonical_alignment.test.ts

Empresa Demo Cuyo solo usa tensiones canónicas. Contiene las 6 esperadas con severidad y priority. No reaparecen los códigos provisorios viejos (TNS-002 caja, TNS-003 stock crítico, TNS-004 inmovilizado).

Test 18 CANÓNICO

Reportes y Score

tests/18_report_score_catalog_alignment.test.ts

El reporte semanal (REP-WEEKLY-2026-W22) y el score_snapshots de mayo 2026 solo referencian tensiones canónicas. El snapshot incluye TNS-001/004/006/009/010 en main_tensions.

Test 19 CANÓNICO

Reglas de cierre por evidencia

tests/19_evidence_closure_rules.test.ts

EVD-004 (comentario simple, trust low) no puede cerrar acción crítica por sí solo. EVD-007 y EVD-012 (trust critical) sí pueden. action_has_required_evidence() bloquea cierres sin evidencia válida.

Test 20 CANÓNICO

Anti-hardcode regression

tests/20_no_hardcoded_catalog_regression.test.ts

Verifica que actionService.ts no contenga const ACTION_CATALOG. Verifica que el motor llame a getActiveTensionDefinition, getActiveActionDefinitionsByCodes y buildEvidenceRequirementsPayload.

Por qué un perro guardián. Test 20 no reemplaza revisión de código, pero ladra antes de que vuelva el desorden. Si alguien copia-pega un catálogo dentro de un service para “ir más rápido”, el commit no llega a main.

05 · Setup técnico

vitest.config.ts + helpers + estructura

Toda la suite vive en un módulo independiente faro-tests/. Helpers para contexto RLS, aserciones de catálogo y motor, fixtures por nivel.

vitest.config.ts CONFIG

Configuración Vitest

Ambiente Node, timeouts 30s (motor + DB), tests secuenciales (no concurrentes) para evitar colisiones de RLS context.

src/helpers/dbTestContext.ts HELPER

Contexto DB con RLS

withTestDbContext abre transacción, hace set_config('app.company_id', ...), ejecuta el callback, y rollback final. Limpia sin ensuciar la base.

src/helpers/catalogAssertions.ts HELPER · NUEVO

Aserciones de catálogo

expectNoRows(client, sql), expectRowCount(client, sql, n), getScalarNumber(). Producen errores con cuerpo de filas para diagnóstico inmediato.

src/helpers/engineAssertions.ts HELPER · NUEVO

Aserciones de motor

assertTensionsHaveCanonicalPayload(client, companyId) y assertActionsHaveCanonicalPayload(). Verifican que el payload JSONB tenga las claves canónicas obligatorias.

src/helpers/sqlChecks.ts HELPER · NUEVO

SQL checks reutilizables

Diccionario con queries de control: activeTensionsCount, rulesWithoutCanonicalTension, tensionActionsWithoutCatalog, actionEvidenceWithoutCatalog, etc.

src/fixtures/ DATA

Fixtures de prueba

YAML válidos / inválidos en rules/. Snapshots JSON de KPIs en kpis/. Expected JSON en expected/ (incluye los dos canonical fixtures de la sección 6).

.env.test.example ENV

Variables de entorno

DATABASE_URL, dos FARO_TEST_COMPANY_* y dos FARO_TEST_USER_* para los tests RLS multiempresa. Roles default: integration_service, faro_owner, company_admin.

package.json SCRIPTS

Scripts npm

test:unit, test:integration, test:demo, test:catalog (base 002) y test:catalogs, test:engine-catalogs, test:demo-canonical, test:evidence-rules, test:no-hardcode, test:canonical (002.1).

Ver vitest.config.ts completo
▸ vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["tests/**/*.test.ts"],
    testTimeout: 30000,
    hookTimeout: 30000,
    sequence: {
      concurrent: false
    }
  }
});
Ver dbTestContext.ts (helper RLS)
▸ src/helpers/dbTestContext.ts
import pg from "pg";
import dotenv from "dotenv";

dotenv.config();
const { Pool } = pg;

if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL is required for tests");
}

export const testPool = new Pool({
  connectionString: process.env.DATABASE_URL
});

export async function withTestDbContext<T>(
  params: {
    companyId: string;
    userId?: string | null;
    roleCodes?: string[];
  },
  fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
  const client = await testPool.connect();

  try {
    await client.query("BEGIN");

    await client.query(
      `SELECT set_config('app.company_id', $1, true)`,
      [params.companyId]
    );

    if (params.userId) {
      await client.query(
        `SELECT set_config('app.user_id', $1, true)`,
        [params.userId]
      );
    }

    await client.query(
      `SELECT set_config('app.role_codes', $1, true)`,
      [(params.roleCodes ?? ["integration_service", "faro_owner", "company_admin"]).join(",")]
    );

    const result = await fn(client);

    await client.query("ROLLBACK");

    return result;
  } finally {
    client.release();
  }
}
Ver sqlChecks.ts (queries canónicas reutilizables)
▸ src/helpers/sqlChecks.ts
export const sqlChecks = {
  activeTensionsCount: `
    SELECT COUNT(*) AS count
    FROM faro.tension_definitions
    WHERE status = 'active'
  `,

  activeActionsCount: `
    SELECT COUNT(*) AS count
    FROM faro.action_definitions
    WHERE status = 'active'
  `,

  activeEvidenceCount: `
    SELECT COUNT(*) AS count
    FROM faro.evidence_definitions
    WHERE status = 'active'
  `,

  rulesWithoutCanonicalTension: `
    SELECT rd.rule_code, rd.rule_body->>'tension_code' AS tension_code
    FROM faro.rule_definitions rd
    LEFT JOIN faro.tension_definitions td
      ON td.tension_code = rd.rule_body->>'tension_code'
     AND td.status = 'active'
    WHERE rd.status = 'active'
      AND rd.rule_type = 'tension'
      AND td.tension_code IS NULL
  `,

  ruleTensionNumberMismatch: `
    SELECT rule_code, rule_body->>'tension_code' AS tension_code
    FROM faro.rule_definitions
    WHERE status = 'active'
      AND rule_code LIKE 'RULE-TNS-%'
      AND regexp_replace(rule_code, '^RULE-TNS-', '') <>
          regexp_replace(rule_body->>'tension_code', '^TNS-', '')
  `,

  tensionActionsWithoutCatalog: `
    SELECT td.tension_code, td.name AS tension_name, action_code
    FROM faro.tension_definitions td
    CROSS JOIN LATERAL unnest(td.recommended_actions) AS action_code
    LEFT JOIN faro.action_definitions ad
      ON ad.action_code = action_code
     AND ad.status = 'active'
    WHERE td.status = 'active'
      AND ad.action_code IS NULL
  `,

  actionEvidenceWithoutCatalog: `
    SELECT ad.action_code, ad.name AS action_name, evidence_code
    FROM faro.action_definitions ad
    CROSS JOIN LATERAL unnest(ad.evidence_required_codes) AS evidence_code
    LEFT JOIN faro.evidence_definitions ed
      ON ed.evidence_code = evidence_code
     AND ed.status = 'active'
    WHERE ad.status = 'active'
      AND ed.evidence_code IS NULL
  `
};
06 · Fixtures expected JSON

canonical_demo + canonical_catalog

Los dos fixtures expected del bloque canónico viven en src/fixtures/expected/. Definen qué tensiones debe disparar la Empresa Demo y qué cantidad mínima de catálogo debe existir.

▸ src/fixtures/expected/canonical_demo_expected.json
{
  "company_id": "10000000-0000-0000-0000-000000000001",
  "period_start": "2026-05-01",
  "period_end": "2026-05-31",
  "expected_tensions": [
    {
      "tension_code": "TNS-001",
      "name": "Crecimiento no rentable",
      "expected_rule_code": "RULE-TNS-001",
      "minimum_priority_score": 90,
      "severity": "critical",
      "expected_actions": ["ACT-COM-001", "ACT-COM-002", "ACT-COM-003"],
      "expected_evidence": ["EVD-007", "EVD-012"]
    },
    {
      "tension_code": "TNS-004",
      "name": "Venta sin conversión a caja",
      "expected_rule_code": "RULE-TNS-004",
      "minimum_priority_score": 80,
      "severity": "critical",
      "expected_actions": ["ACT-FIN-001", "ACT-FIN-002", "ACT-FIN-003"],
      "expected_evidence": ["EVD-011", "EVD-006", "EVD-012"]
    },
    {
      "tension_code": "TNS-006",
      "name": "Stock crítico en productos de alta rotación",
      "expected_rule_code": "RULE-TNS-006",
      "minimum_priority_score": 70,
      "severity": "high",
      "expected_actions": ["ACT-STK-001", "ACT-STK-002"],
      "expected_evidence": ["EVD-005", "EVD-002"]
    },
    {
      "tension_code": "TNS-007",
      "name": "Stock inmovilizado",
      "expected_rule_code": "RULE-TNS-007",
      "minimum_priority_score": 60,
      "severity": "high",
      "expected_actions": ["ACT-STK-003", "ACT-COM-001"],
      "expected_evidence": ["EVD-010", "EVD-012"]
    },
    {
      "tension_code": "TNS-009",
      "name": "Acciones vencidas",
      "expected_rule_code": "RULE-TNS-009",
      "minimum_priority_score": 70,
      "severity": "high",
      "expected_actions": ["ACT-OPS-001", "ACT-DIR-001"],
      "expected_evidence": ["EVD-004", "EVD-012"]
    },
    {
      "tension_code": "TNS-010",
      "name": "Acciones sin evidencia",
      "expected_rule_code": "RULE-TNS-010",
      "minimum_priority_score": 70,
      "severity": "high",
      "expected_actions": ["ACT-OPS-002", "ACT-DIR-001"],
      "expected_evidence": ["EVD-004", "EVD-012"]
    }
  ]
}
▸ src/fixtures/expected/canonical_catalog_expected.json
{
  "expected_counts": {
    "tension_definitions": 30,
    "action_definitions": 30,
    "evidence_definitions": 12
  },
  "must_exist": {
    "tensions": ["TNS-001", "TNS-004", "TNS-006", "TNS-007", "TNS-009", "TNS-010"],
    "actions": ["ACT-COM-001", "ACT-FIN-001", "ACT-STK-001", "ACT-OPS-001", "ACT-OPS-002", "ACT-DIR-001", "ACT-DQ-001"],
    "evidence": ["EVD-001", "EVD-002", "EVD-004", "EVD-007", "EVD-010", "EVD-011", "EVD-012"]
  },
  "critical_evidence": ["EVD-007", "EVD-012"],
  "weak_evidence": ["EVD-004"]
}

Trayectoria Score documentada. Para Empresa Demo Cuyo S.A. con período 2026-05-01 / 2026-05-31, el score esperado pasa de 74 (baseline antes del motor) a 66 (después de aplicar las 6 tensiones disparadas con sus respectivos score_impact). Esta diferencia −8 es el fixture vinculante en tests/11_demo_regression.test.ts y tests/18_report_score_catalog_alignment.test.ts.

07 · Tests críticos

Cinco tests representativos del bloque base + canónico

Se incluyen los cinco tests más representativos en TypeScript verbatim. El resto se documenta por título y archivo en las secciones 3 y 4.

7.1 · Condition evaluator (all / any / none)

▸ tests/03_condition_evaluator.test.ts
import { describe, expect, it } from "vitest";
import { evaluateConditionGroup } from "../src/engine/conditionEvaluator";

const snapshots: any = {
  "KPI-SAL-001": { value: 36102650, deltaPct: 0.1798, confidenceScore: 89, status: "ok" },
  "KPI-SAL-002": { value: 0.213, deltaPct: -0.2392, confidenceScore: 87, status: "critical" },
  "KPI-SAL-003": { value: 0.123, deltaPct: 0.9838, confidenceScore: 86, status: "critical" }
};

describe("Condition Evaluator", () => {
  it("passes all conditions for growth not profitable", () => {
    const result = evaluateConditionGroup(
      {
        all: [
          { kpi: "KPI-SAL-001", metric: "delta_pct", operator: ">=", value: 0.10 },
          { kpi: "KPI-SAL-002", metric: "delta_pct", operator: "<=", value: -0.10 },
          { kpi: "KPI-SAL-003", metric: "delta_pct", operator: ">=", value: 0.30 }
        ]
      },
      snapshots
    );
    expect(result.passed).toBe(true);
  });

  it("supports any condition group", () => {
    const result = evaluateConditionGroup(
      { any: [{ kpi: "KPI-SAL-003", metric: "value", operator: ">=", value: 0.10 }] },
      snapshots
    );
    expect(result.passed).toBe(true);
  });

  it("supports none condition group", () => {
    const result = evaluateConditionGroup(
      { none: [{ kpi: "KPI-SAL-002", metric: "value", operator: ">=", value: 0.30 }] },
      snapshots
    );
    expect(result.passed).toBe(true);
  });
});

7.2 · Severity calculator + score impact

▸ tests/05_severity_score_impact.test.ts
import { describe, expect, it } from "vitest";
import { calculateSeverity } from "../src/engine/severityCalculator";
import { calculateScoreImpact } from "../src/services/scoreImpactService";

describe("Severity and Score Impact", () => {
  it("escalates to critical when escalation condition passes", () => {
    const rule: any = {
      ruleBody: {
        severity: {
          default: "high",
          escalation: [
            {
              when: {
                all: [
                  { kpi: "KPI-SAL-002", metric: "value", operator: "<=", value: 0.22 },
                  { kpi: "KPI-SAL-003", metric: "value", operator: ">=", value: 0.10 }
                ]
              },
              set: "critical"
            }
          ]
        }
      }
    };

    const severity = calculateSeverity(rule, {
      "KPI-SAL-002": { value: 0.213 },
      "KPI-SAL-003": { value: 0.123 }
    } as any);

    expect(severity).toBe("critical");
  });

  it("calculates score impact with critical multiplier and cap", () => {
    const impact = calculateScoreImpact({
      base: -8,
      max: -12,
      severity: "critical"
    });

    expect(impact).toBeLessThanOrEqual(-8);
    expect(impact).toBeGreaterThanOrEqual(-12);
  });
});

7.3 · Idempotencia (doble corrida del motor)

▸ tests/08_idempotency.test.ts
import { describe, expect, it } from "vitest";
import { withTestDbContext } from "../src/helpers/dbTestContext";
import { runEvaluation } from "../src/services/evaluationService";

const COMPANY_ID = "10000000-0000-0000-0000-000000000001";
const USER_ID = "12000000-0000-0000-0000-000000000001";

describe("Idempotency", () => {
  it("does not duplicate open tensions and actions when run twice", async () => {
    await withTestDbContext(
      {
        companyId: COMPANY_ID,
        userId: USER_ID,
        roleCodes: ["integration_service", "faro_owner", "company_admin"]
      },
      async (client) => {
        const first = await runEvaluation(client, {
          companyId: COMPANY_ID, userId: USER_ID,
          roleCodes: ["integration_service", "faro_owner", "company_admin"],
          periodStart: "2026-05-01", periodEnd: "2026-05-31",
          dryRun: false, createActions: true
        });

        const second = await runEvaluation(client, {
          companyId: COMPANY_ID, userId: USER_ID,
          roleCodes: ["integration_service", "faro_owner", "company_admin"],
          periodStart: "2026-05-01", periodEnd: "2026-05-31",
          dryRun: false, createActions: true
        });

        expect(first.rulesTriggered).toBeGreaterThan(0);
        expect(second.rulesTriggered).toBeGreaterThan(0);

        expect(second.tensionsCreated).toBe(0);
        expect(second.tensionsUpdated).toBeGreaterThanOrEqual(1);
        expect(second.actionsCreated).toBe(0);
      }
    );
  });
});

7.4 · RLS multiempresa (Tenant A no ve Tenant B)

▸ tests/10_rls_multiempresa.test.ts
import { describe, expect, it } from "vitest";
import { testPool } from "../src/helpers/dbTestContext";

const COMPANY_A = "10000000-0000-0000-0000-000000000001";
const COMPANY_B = "20000000-0000-0000-0000-000000000001";

describe("RLS Multiempresa", () => {
  it("Tenant A cannot see Tenant B data", async () => {
    const client = await testPool.connect();
    try {
      await client.query("BEGIN");
      await client.query(`SELECT set_config('app.company_id', $1, true)`, [COMPANY_A]);
      await client.query(`SELECT set_config('app.user_id', $1, true)`, ["12000000-0000-0000-0000-000000000001"]);
      await client.query(`SELECT set_config('app.role_codes', $1, true)`, ["director"]);

      const result = await client.query(`SELECT DISTINCT company_id FROM faro.kpi_snapshots`);

      expect(result.rows.every((row) => row.company_id === COMPANY_A)).toBe(true);

      await client.query("ROLLBACK");
    } finally {
      client.release();
    }
  });

  it("without context returns no operational rows", async () => {
    const client = await testPool.connect();
    try {
      await client.query("BEGIN");
      await client.query(`RESET app.company_id`);
      await client.query(`RESET app.user_id`);
      await client.query(`RESET app.role_codes`);

      const result = await client.query(`SELECT * FROM faro.tensions`);
      expect(result.rows.length).toBe(0);

      await client.query("ROLLBACK");
    } finally {
      client.release();
    }
  });
});

7.5 · Anti-hardcode regression (catálogo no vuelve al código)

▸ tests/20_no_hardcoded_catalog_regression.test.ts
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";

function readFileIfExists(filePath: string): string {
  if (!fs.existsSync(filePath)) return "";
  return fs.readFileSync(filePath, "utf8");
}

describe("No hardcoded catalog regression", () => {
  it("actionService does not contain ACTION_CATALOG hardcoded object", () => {
    const content = readFileIfExists(path.resolve("src/services/actionService.ts"));
    expect(content).not.toContain("const ACTION_CATALOG");
    expect(content).not.toContain("ACTION_CATALOG:");
  });

  it("engine uses canonical catalog repositories", () => {
    const evaluationService = readFileIfExists(path.resolve("src/services/evaluationService.ts"));
    const actionService = readFileIfExists(path.resolve("src/services/actionService.ts"));

    expect(evaluationService).toContain("getActiveTensionDefinition");
    expect(actionService).toContain("getActiveActionDefinitionsByCodes");
    expect(actionService).toContain("buildEvidenceRequirementsPayload");
  });
});
08 · SQL checks manuales

Cinco queries listos para correr en psql

Más allá de Vitest, todo desarrollador puede ejecutar manualmente estos checks contra la base. Si alguno devuelve filas distintas a lo esperado, hay un desalineamiento canónico antes de tocar el motor.

8.1 · Conteos catálogo Esperado: 30 / 30 / 12

Cantidades canónicas de cada catálogo

Los tres catálogos deben tener exactamente 30 tensiones, 30 acciones y 12 evidencias activas. Cualquier desvío rompe los tests 14 y 15.

▸ SQL
SELECT 'tensions' AS catalog, COUNT(*) AS total
FROM faro.tension_definitions
WHERE status = 'active'

UNION ALL

SELECT 'actions' AS catalog, COUNT(*) AS total
FROM faro.action_definitions
WHERE status = 'active'

UNION ALL

SELECT 'evidence' AS catalog, COUNT(*) AS total
FROM faro.evidence_definitions
WHERE status = 'active';
8.2 · Regla → tensión Esperado: 0 filas

Reglas que apuntan a tensiones inexistentes

Si una regla activa referencia un tension_code no presente en el catálogo canónico, hay un huérfano. Bloqueador inmediato para deploy.

▸ SQL
SELECT
  rd.rule_code,
  rd.rule_body->>'tension_code' AS tension_code
FROM faro.rule_definitions rd
LEFT JOIN faro.tension_definitions td
  ON td.tension_code = rd.rule_body->>'tension_code'
 AND td.status = 'active'
WHERE rd.status = 'active'
  AND td.tension_code IS NULL;
8.3 · Tensión → acción Esperado: 0 filas

Tensiones que recomiendan acciones fuera de catálogo

Toda recommended_actions[*] de tension_definitions debe resolver en action_definitions. Si no, el motor crea acciones huérfanas sin evidencia ni SLA.

▸ SQL
SELECT
  td.tension_code,
  td.name,
  action_code
FROM faro.tension_definitions td
CROSS JOIN LATERAL unnest(td.recommended_actions) AS action_code
LEFT JOIN faro.action_definitions ad
  ON ad.action_code = action_code
 AND ad.status = 'active'
WHERE td.status = 'active'
  AND ad.action_code IS NULL;
8.4 · Acción → evidencia Esperado: 0 filas

Acciones que requieren evidencias fuera de catálogo

Toda evidence_required_codes[*] de action_definitions debe existir en evidence_definitions. Sin evidencia válida no hay cierre confiable.

▸ SQL
SELECT
  ad.action_code,
  ad.name,
  evidence_code
FROM faro.action_definitions ad
CROSS JOIN LATERAL unnest(ad.evidence_required_codes) AS evidence_code
LEFT JOIN faro.evidence_definitions ed
  ON ed.evidence_code = evidence_code
 AND ed.status = 'active'
WHERE ad.status = 'active'
  AND ed.evidence_code IS NULL;
8.5 · Demo final canónica Esperado: 6 filas mínimo

Lista verificada de tensiones de Empresa Demo Cuyo

Listado priorizado de las tensiones detectadas para la demo, con nombre canónico, severidad, priority y cantidad de acciones asociadas. Mínimo: TNS-001, TNS-004, TNS-006, TNS-007, TNS-009, TNS-010.

▸ SQL
SELECT
  t.tension_code,
  td.name AS catalog_name,
  t.title AS detected_title,
  t.severity,
  t.priority_score,
  COUNT(a.action_id) AS actions_count
FROM faro.tensions t
JOIN faro.tension_definitions td
  ON td.tension_code = t.tension_code
 AND td.status = 'active'
LEFT JOIN faro.actions a
  ON a.tension_id = t.tension_id
WHERE t.company_id = '10000000-0000-0000-0000-000000000001'
GROUP BY t.tension_code, td.name, t.title, t.severity, t.priority_score
ORDER BY t.priority_score DESC;
09 · CI · GitHub Actions

Workflow consolidado · base + canónico

El CI levanta PostgreSQL 15 como servicio, instala dependencias, aplica migraciones, seeds canónicos, seed demo, patch demo canónico, valida YAML y corre la suite completa. Bloqueante para merge a main.

▸ .github/workflows/faro-canonical-tests.yml
name: FARO Canonical Catalog Tests

on:
  pull_request:
    paths:
      - "src/**"
      - "tests/**"
      - "rules/**"
      - "migrations/**"
      - "seeds/**"

jobs:
  canonical-tests:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: faro_app
          POSTGRES_PASSWORD: password
          POSTGRES_DB: faro_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready -U faro_app -d faro_test"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    env:
      DATABASE_URL: postgresql://faro_app:password@localhost:5432/faro_test

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci

      - name: Typecheck
        run: npm run typecheck

      - name: Run migrations
        run: npm run db:migrate:test

      - name: Seed canonical catalogs
        run: npm run db:seed:catalogs

      - name: Seed demo company
        run: npm run db:seed:demo

      - name: Patch demo canonical alignment
        run: npm run db:patch:demo-canonical

      - name: Validate YAML rules
        run: npm run validate

      - name: Run base rule tests
        run: npm run test:rules

      - name: Run canonical catalog tests
        run: npm run test:canonical

      - name: Run full MVP test suite
        run: npm run test

Output esperado del test suite

Schema Validation Parser Validation Condition Evaluator Confidence Policy Severity and Score Impact Rule Engine Unit Rule Engine Integration Idempotency Actions and Evidence RLS Multiempresa Demo Regression Catalog Consistency Catalog alignment: rules → tensions Catalog alignment: tensions → actions Catalog alignment: actions → evidence Engine canonical payload Demo canonical alignment Reports and Score catalog alignment Evidence closure rules No hardcoded catalog regression Test Files: 20 passed (20) Tests: passed Errors: 0 Warnings: 0
10 · Orden de ejecución en entorno limpio

11 pasos secuenciales para reproducir la suite

Si saltás este orden, los tests fallan por dependencia, no por bug. Mejor error temprano que demo rota con sonrisa.

Migraciones base · FARO-SQL-001

Schema faro + tablas core (companies, users, kpi_snapshots, tensions, actions, rule_definitions, rule_evaluations).

RLS multiempresa · FARO-SQL-002

Políticas RLS por company_id, roles operativos y configuración app.company_id / app.user_id / app.role_codes.

Seed Empresa Demo · FARO-SQL-003

Inserta Empresa Demo Cuyo S.A. con UUID fijo, usuarios técnicos y KPIs snapshot del período 2026-05-01 / 2026-05-31.

Catálogo Tensiones · FARO-SQL-004

DDL tension_definitions + seed con las 30 fichas canónicas TNS-001..TNS-030 (ver catalogo-tensiones-mvp.html).

Patch Empresa Demo · FARO-SQL-003.1

Re-alinea el seed demo viejo a códigos canónicos (TNS-002 venta-caja → TNS-004, TNS-003 stock crítico → TNS-006, etc.). Debe correrse después de FARO-SQL-004.

Catálogo Evidencias · FARO-SQL-006

DDL evidence_definitions + seed con 12 evidencias canónicas y reglas de cierre (ver catalogo-evidencias-mvp.html).

Parser YAML · FARO-ENG-002

Parser DSL de reglas YAML con validación de schema, KPIs requeridos, operadores y referencias al catálogo (ver reglas-mvp-yaml.html).

Motor + patch · FARO-ENG-003 + 003.1

Motor evaluador (ver motor-evaluador-mvp.html) + patch que enriquece payload con los tres catálogos canónicos.

Tests base · FARO-TEST-002

npm run test:unit && npm run test:integration && npm run test:demo && npm run test:catalog. Cubre tests/01..12.

Tests canónicos · FARO-TEST-002.1

npm run test:canonical. Cubre tests/13..20. Es el último paso antes de declarar el motor listo para UI.

Ojo con el orden. Si el patch demo (paso 5) corre antes del catálogo de tensiones (paso 4), va a fallar el FK / la validación de catálogo. Bien que falle. El orden es defensa, no decoración.

11 · Criterios de aceptación

19 criterios verificables · todos en verde para aprobar

FARO-TEST-002 + 002.1 quedan aceptados cuando los 19 criterios siguientes están en verde sobre la base de test. Si uno falla, la suite no se considera aprobada para merge.

30 tensiones activas en tension_definitions
30 acciones activas en action_definitions
12 evidencias activas en evidence_definitions
Toda regla activa apunta a tensión canónica
Número de RULE-TNS-NNN coincide con TNS-NNN
Toda tensión recomienda acciones existentes
Toda acción exige evidencias existentes
Toda acción activa tiene closure_criteria
Toda acción activa tiene default_sla_days
Motor crea tensiones con payload canónico
Motor crea acciones con payload canónico
Motor no duplica al correrse dos veces
Empresa Demo usa solo tensiones canónicas
Demo dispara TNS-001/004/006/007/009/010
Reporte semanal usa códigos canónicos
Score snapshot usa códigos canónicos
Acción crítica no cierra solo con EVD-004
RLS aísla Tenant A de Tenant B
No reaparece ACTION_CATALOG hardcodeado

Frase ejecutiva. Sin tests, “creemos que funciona”. Con tests, “corre, valida, falla cuando debe fallar y genera el resultado esperado”. Una vende humo. La otra vende sistema. La diferencia se cobra frente a un CTO en la primera reunión.

12 · Cross-references

Dónde se conecta esta suite con el resto del pack

Los tests no validan documentos: validan piezas de producto. Cada referencia siguiente es algo concreto que la suite verifica.

Roadmap · próximas suites

  1. FARO-UI-TEST · Tests visuales de UI. Playwright sobre la bandeja de tensiones (FARO-UI-001). Validar que el render usa el payload canónico del motor, no strings hardcodeados.
  2. FARO-PERF-001 · Tests de carga. Verificar comportamiento del motor con 1M de KPI snapshots y 100 empresas concurrentes. Garantizar que el RLS no se degrada bajo carga.
  3. FARO-AI-TEST · Tests de IA explicativa. Cuando se incorpore el módulo IA que explica por qué se disparó una tensión, validar que cita correctamente business_question, executive_diagnosis y evidence_required.
  4. FARO-SIM-TEST · Tests de simulación. Validar el motor en modo dry-run con datasets sintéticos para probar escenarios “qué pasaría si” antes de aplicarlos en producción.
  5. FARO-TPL-TEST · Tests de templates / PDF. Validar que los templates de email de alertas y el reporte semanal PDF renderizan correctamente los códigos canónicos sin texto huérfano.