# FARO-TEST-002 · Tests Reglas MVP

**Código:** FARO-TEST-002
**Nombre:** Tests Reglas MVP FARO Connect
**Versión:** v1.0
**Estado:** Framework de QA técnico para reglas y motor evaluador
**Prioridad:** P1 · Crítico antes de demo ejecutable
**Lenguaje recomendado:** TypeScript / Vitest
**Base de datos:** PostgreSQL 15+
**Depende de:**

* FARO-CFG-001 · Reglas MVP en YAML
* FARO-ENG-002 · DSL Parser Reglas YAML
* FARO-ENG-003 · Motor Evaluador MVP
* FARO-SQL-001 · Migraciones Base MVP
* FARO-SQL-002 · Multiempresa, Roles y RLS
* FARO-SQL-003 · Seeds Empresa Demo

**Conecta con:**

* FARO-DEMO-001 · Dataset Demo Integral
* FARO-UI-001 · Bandeja de Tensiones
* FARO-TPL-001 · Templates Email Alertas
* FARO-TPL-002 · Reporte Semanal Ejecutivo

---

# 1. Objetivo

El objetivo de FARO-TEST-002 es definir el sistema de pruebas que valida que las reglas MVP de FARO funcionen correctamente.

Debe probar que:

```text
YAML válido
→ parser válido
→ regla importada
→ KPIs disponibles
→ motor evalúa bien
→ tensión correcta
→ severidad correcta
→ acción correcta
→ evidencia requerida
→ no duplica
→ respeta company_id
→ no inventa datos
```

Este documento cubre tests para:

* reglas YAML;
* parser;
* motor evaluador;
* condiciones;
* severidad;
* confianza del dato;
* datos faltantes;
* idempotencia;
* creación de tensiones;
* creación de acciones;
* evidencia;
* impacto en Score;
* RLS/multiempresa;
* regresión del dataset demo.

---

# 2. Advertencia crítica: alinear códigos TNS

Antes de testear, hay que resolver una cosa importante.

En lo construido hasta ahora hay una posible diferencia de codificación entre piezas:

| Pieza        | Ejemplo                                                              |
| ------------ | -------------------------------------------------------------------- |
| FARO-SQL-003 | `TNS-001` = Crecimiento no rentable, `TNS-002` = Venta sin caja      |
| FARO-CFG-001 | `TNS-001` = Crecimiento no rentable, pero `TNS-004` = Venta sin caja |

Esto no es grave todavía, pero si se deja pasar, el motor puede crear una tensión con un código y la UI o el reporte esperar otro. Ahí nace el clásico “funciona, pero no aparece”. El cementerio de los MVP está lleno de esos.

## Decisión recomendada

Crear un catálogo canónico único:

```text
FARO-CAT-001 · Catálogo Canónico de Tensiones MVP
```

Mientras tanto, FARO-TEST-002 debe incluir un test de consistencia:

```text
Toda regla YAML debe tener tension_code existente en catálogo oficial.
Todo seed SQL debe usar tension_code existente en catálogo oficial.
Todo reporte/demo debe referenciar tension_code existente en catálogo oficial.
```

---

# 3. Tesis técnica

Las reglas FARO no pueden validarse “a ojo”.

Un sistema de dirección ejecutiva necesita pruebas porque:

1. una regla mal escrita genera falsas alarmas;
2. una regla muy blanda llena la bandeja de ruido;
3. una regla muy dura no detecta problemas reales;
4. un KPI faltante puede generar falsa precisión;
5. una tensión sin acción no sirve;
6. una acción sin evidencia es teatro;
7. una corrida duplicada puede destruir la confianza;
8. una falla de RLS puede mezclar empresas;
9. una diferencia de código puede romper reportes;
10. una demo sin tests es una presentación con fe.

La fe sirve para misa. Para un CTO, tests.

---

# 4. Alcance

## 4.1 Incluye

| Tipo de test                        | Incluido |
| ----------------------------------- | -------: |
| Tests de schema YAML                |       Sí |
| Tests de parser                     |       Sí |
| Tests de operadores                 |       Sí |
| Tests de KPIs requeridos            |       Sí |
| Tests de acciones recomendadas      |       Sí |
| Tests de roles                      |       Sí |
| Tests de evidencia                  |       Sí |
| Tests de condiciones `all/any/none` |       Sí |
| Tests de severidad                  |       Sí |
| Tests de confianza de dato          |       Sí |
| Tests de datos faltantes            |       Sí |
| Tests de idempotencia               |       Sí |
| Tests de creación de tensiones      |       Sí |
| Tests de creación de acciones       |       Sí |
| Tests de Score impact               |       Sí |
| Tests RLS / multiempresa            |       Sí |
| Tests dataset demo                  |       Sí |
| Tests CI/CD                         |       Sí |

## 4.2 No incluye

| Elemento                           | Motivo             | Próximo activo |
| ---------------------------------- | ------------------ | -------------- |
| Tests de UI visual                 | Corresponde a UI   | FARO-UI-TEST   |
| Tests de carga masiva 1M registros | Fase performance   | FARO-PERF-001  |
| Tests IA explicativa               | Fase IA            | FARO-AI-TEST   |
| Tests de simulación avanzada       | Fase posterior     | FARO-SIM-TEST  |
| Tests de PDF final                 | Templates/reportes | FARO-TPL-TEST  |

---

# 5. Tipos de pruebas oficiales

| Nivel       | Qué prueba                                            | Herramienta       |
| ----------- | ----------------------------------------------------- | ----------------- |
| Unit        | Funciones puras: condiciones, severidad, score impact | Vitest            |
| Parser      | YAML válido/inválido                                  | Vitest + fixtures |
| Contract    | Regla cumple schema y catálogo                        | Vitest            |
| Engine      | Motor evalúa reglas contra KPIs mock                  | Vitest            |
| Integration | Motor escribe en PostgreSQL                           | Vitest + DB test  |
| RLS         | Tenant A no ve Tenant B                               | SQL/Vitest        |
| Idempotency | No duplica tensiones/acciones                         | DB test           |
| Regression  | Dataset demo genera tensiones esperadas               | Expected JSON     |
| CI          | Todo corre en pull request                            | GitHub Actions    |

---

# 6. Estructura recomendada del módulo

```text
faro-tests/
  package.json
  tsconfig.json
  vitest.config.ts
  .env.test.example
  README.md

  src/
    helpers/
      dbTestContext.ts
      seedTestData.ts
      cleanupTestData.ts
      loadFixture.ts
      assertDb.ts

    fixtures/
      rules/
        valid/
          RULE-TNS-001_crecimiento_no_rentable.yaml
        invalid/
          missing_rule_code.yaml
          invalid_operator.yaml
          unknown_kpi.yaml
          no_evidence.yaml
      kpis/
        growth_not_profitable.json
        sale_without_cash.json
        low_confidence.json
        missing_kpis.json
      expected/
        demo_expected_tensions.json
        demo_expected_actions.json
        demo_expected_score.json

  tests/
    01_schema_validation.test.ts
    02_parser_validation.test.ts
    03_condition_evaluator.test.ts
    04_confidence_policy.test.ts
    05_severity_score_impact.test.ts
    06_rule_engine_unit.test.ts
    07_rule_engine_integration.test.ts
    08_idempotency.test.ts
    09_actions_evidence.test.ts
    10_rls_multiempresa.test.ts
    11_demo_regression.test.ts
    12_catalog_consistency.test.ts
```

---

# 7. package.json

```json
{
  "name": "@faro/tests-rules-mvp",
  "version": "1.0.0",
  "description": "FARO Connect MVP Rules Test Suite",
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:unit": "vitest run tests/01_schema_validation.test.ts tests/02_parser_validation.test.ts tests/03_condition_evaluator.test.ts tests/04_confidence_policy.test.ts tests/05_severity_score_impact.test.ts tests/06_rule_engine_unit.test.ts",
    "test:integration": "vitest run tests/07_rule_engine_integration.test.ts tests/08_idempotency.test.ts tests/09_actions_evidence.test.ts tests/10_rls_multiempresa.test.ts",
    "test:demo": "vitest run tests/11_demo_regression.test.ts",
    "test:catalog": "vitest run tests/12_catalog_consistency.test.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "pg": "^8.13.1",
    "yaml": "^2.6.1",
    "ajv": "^8.17.1",
    "ajv-formats": "^3.0.1"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "@types/pg": "^8.11.10",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2",
    "vitest": "^2.1.8"
  }
}
```

---

# 8. vitest.config.ts

```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
    }
  }
});
```

---

# 9. .env.test.example

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

FARO_TEST_COMPANY_A=10000000-0000-0000-0000-000000000001
FARO_TEST_COMPANY_B=20000000-0000-0000-0000-000000000001

FARO_TEST_USER_A=12000000-0000-0000-0000-000000000001
FARO_TEST_USER_B=22000000-0000-0000-0000-000000000001

FARO_TEST_ROLE_CODES=integration_service,faro_owner,company_admin
```

---

# 10. Helper DB con contexto RLS

```ts
// 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();
  }
}
```

Nota: en tests usamos `ROLLBACK` para no ensuciar la base. Para tests de idempotencia se puede usar transacción controlada o schema temporal.

---

# 11. Fixture KPI · Crecimiento no rentable

```json
{
  "KPI-SAL-001": {
    "kpiCode": "KPI-SAL-001",
    "value": 36102650,
    "referenceValue": 30600000,
    "deltaValue": 5502650,
    "deltaPct": 0.1798,
    "confidenceScore": 89,
    "status": "ok"
  },
  "KPI-SAL-002": {
    "kpiCode": "KPI-SAL-002",
    "value": 0.213,
    "referenceValue": 0.28,
    "deltaValue": -0.067,
    "deltaPct": -0.2392,
    "confidenceScore": 87,
    "status": "critical"
  },
  "KPI-SAL-003": {
    "kpiCode": "KPI-SAL-003",
    "value": 0.123,
    "referenceValue": 0.062,
    "deltaValue": 0.061,
    "deltaPct": 0.9838,
    "confidenceScore": 86,
    "status": "critical"
  }
}
```

---

# 12. Expected · Tensiones demo

```json
[
  {
    "tension_code": "TNS-001",
    "title": "Crecimiento no rentable",
    "severity": "critical",
    "should_trigger": true,
    "minimum_priority_score": 90,
    "expected_actions": ["ACT-COM-001", "ACT-COM-002", "ACT-COM-003"],
    "expected_evidence": ["EVD-007", "EVD-012"]
  },
  {
    "tension_code": "TNS-004",
    "title": "Venta sin conversión a caja",
    "severity": "critical",
    "should_trigger": true,
    "expected_actions": ["ACT-FIN-001", "ACT-FIN-002", "ACT-FIN-003"],
    "expected_evidence": ["EVD-011", "EVD-006", "EVD-012"]
  },
  {
    "tension_code": "TNS-006",
    "title": "Stock crítico en productos de alta rotación",
    "severity": "high",
    "should_trigger": true,
    "expected_actions": ["ACT-STK-001", "ACT-STK-002"],
    "expected_evidence": ["EVD-005", "EVD-002"]
  }
]
```

**Importante:** este expected debe ajustarse cuando se defina el catálogo canónico final de tensiones. Hoy lo dejo alineado con FARO-CFG-001, no con el seed previo.

---

# 13. Test 01 · Schema validation

```ts
// tests/01_schema_validation.test.ts
import { describe, expect, it } from "vitest";
import { validateSchema } from "../src/validators/validateSchema";
import validRule from "../src/fixtures/rules/valid/RULE-TNS-001_crecimiento_no_rentable.yaml";
import missingRuleCode from "../src/fixtures/rules/invalid/missing_rule_code.yaml";

describe("FARO Rule Schema Validation", () => {
  it("accepts a valid rule", () => {
    const result = validateSchema(validRule);

    expect(result.valid).toBe(true);
    expect(result.issues).toHaveLength(0);
  });

  it("rejects rule without rule_code", () => {
    const result = validateSchema(missingRuleCode);

    expect(result.valid).toBe(false);
    expect(result.issues.some((i) => i.code === "SCHEMA_VALIDATION_ERROR")).toBe(true);
  });
});
```

---

# 14. Test 02 · Parser validation

```ts
// tests/02_parser_validation.test.ts
import { describe, expect, it } from "vitest";
import { parseRuleFile } from "../src/parser/parseRuleFile";

describe("FARO YAML Parser", () => {
  it("parses and normalizes a valid YAML rule", async () => {
    const result = await parseRuleFile(
      "src/fixtures/rules/valid/RULE-TNS-001_crecimiento_no_rentable.yaml"
    );

    expect(result.valid).toBe(true);
    expect(result.normalizedRule?.ruleCode).toBe("RULE-TNS-001");
    expect(result.normalizedRule?.tensionCode).toBe("TNS-001");
  });

  it("rejects invalid operator", async () => {
    const result = await parseRuleFile(
      "src/fixtures/rules/invalid/invalid_operator.yaml"
    );

    expect(result.valid).toBe(false);
    expect(result.issues.some((i) => i.code === "UNSUPPORTED_OPERATOR")).toBe(true);
  });

  it("rejects unknown KPI", async () => {
    const result = await parseRuleFile(
      "src/fixtures/rules/invalid/unknown_kpi.yaml"
    );

    expect(result.valid).toBe(false);
    expect(result.issues.some((i) => i.code === "UNKNOWN_REQUIRED_KPI")).toBe(true);
  });
});
```

---

# 15. Test 03 · Condition evaluator

```ts
// 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);
    expect(result.diagnostics.every((d) => d.passed)).toBe(true);
  });

  it("fails when sales growth is below threshold", () => {
    const result = evaluateConditionGroup(
      {
        all: [
          { kpi: "KPI-SAL-001", metric: "delta_pct", operator: ">=", value: 0.30 }
        ]
      },
      snapshots
    );

    expect(result.passed).toBe(false);
  });

  it("supports any condition group", () => {
    const result = evaluateConditionGroup(
      {
        any: [
          { kpi: "KPI-SAL-001", metric: "delta_pct", operator: ">=", value: 0.30 },
          { 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);
  });
});
```

---

# 16. Test 04 · Confidence policy

```ts
// tests/04_confidence_policy.test.ts
import { describe, expect, it } from "vitest";
import { validateConfidence } from "../src/engine/confidenceValidator";

const rule: any = {
  ruleBody: {
    data_requirements: {
      required_kpis: ["KPI-SAL-001", "KPI-SAL-002", "KPI-SAL-003"],
      minimum_confidence_score: 75
    }
  }
};

describe("Confidence Policy", () => {
  it("passes when average confidence is above minimum", () => {
    const result = validateConfidence(rule, {
      "KPI-SAL-001": { confidenceScore: 89 },
      "KPI-SAL-002": { confidenceScore: 87 },
      "KPI-SAL-003": { confidenceScore: 86 }
    } as any);

    expect(result.passed).toBe(true);
    expect(result.confidenceScore).toBeGreaterThanOrEqual(75);
  });

  it("fails when confidence is below minimum", () => {
    const result = validateConfidence(rule, {
      "KPI-SAL-001": { confidenceScore: 55 },
      "KPI-SAL-002": { confidenceScore: 60 },
      "KPI-SAL-003": { confidenceScore: 58 }
    } as any);

    expect(result.passed).toBe(false);
    expect(result.warnings.length).toBeGreaterThan(0);
  });

  it("warns when confidence score is missing", () => {
    const result = validateConfidence(rule, {
      "KPI-SAL-001": { confidenceScore: null },
      "KPI-SAL-002": { confidenceScore: 80 },
      "KPI-SAL-003": { confidenceScore: 82 }
    } as any);

    expect(result.warnings.some((w) => w.includes("no confidence_score"))).toBe(true);
  });
});
```

---

# 17. Test 05 · Severity y Score impact

```ts
// 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);
  });
});
```

---

# 18. Test 06 · Rule engine unit

```ts
// tests/06_rule_engine_unit.test.ts
import { describe, expect, it } from "vitest";
import { evaluateRule } from "../src/engine/evaluator";

function buildRule(): any {
  return {
    ruleId: "rule-001",
    ruleCode: "RULE-TNS-001",
    severityDefault: "high",
    ruleBody: {
      tension_code: "TNS-001",
      data_requirements: {
        required_kpis: ["KPI-SAL-001", "KPI-SAL-002", "KPI-SAL-003"],
        minimum_confidence_score: 75,
        missing_data_policy: "do_not_trigger",
        stale_data_policy: "warn"
      },
      conditions: {
        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 }
        ]
      },
      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"
          }
        ]
      },
      output: {
        score_impact: { base: -8, max: -12 }
      }
    }
  };
}

describe("Rule Engine Unit", () => {
  it("triggers TNS-001 with critical severity", () => {
    const result = evaluateRule({
      rule: buildRule(),
      snapshots: {
        "KPI-SAL-001": { deltaPct: 0.1798, confidenceScore: 89 },
        "KPI-SAL-002": { value: 0.213, deltaPct: -0.2392, confidenceScore: 87 },
        "KPI-SAL-003": { value: 0.123, deltaPct: 0.9838, confidenceScore: 86 }
      } as any
    });

    expect(result.triggered).toBe(true);
    expect(result.severity).toBe("critical");
    expect(result.priorityScore).toBeGreaterThanOrEqual(90);
    expect(result.scoreImpact).toBeLessThan(0);
  });

  it("does not trigger when required KPI is missing", () => {
    const result = evaluateRule({
      rule: buildRule(),
      snapshots: {
        "KPI-SAL-001": { deltaPct: 0.1798, confidenceScore: 89 }
      } as any
    });

    expect(result.triggered).toBe(false);
    expect(result.missingKpis).toContain("KPI-SAL-002");
    expect(result.missingKpis).toContain("KPI-SAL-003");
  });
});
```

---

# 19. Test 07 · Integration engine DB

```ts
// tests/07_rule_engine_integration.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("Rule Engine Integration", () => {
  it("runs evaluation and creates rule evaluations in dry-run false mode", async () => {
    await withTestDbContext(
      {
        companyId: COMPANY_ID,
        userId: USER_ID,
        roleCodes: ["integration_service", "faro_owner", "company_admin"]
      },
      async (client) => {
        const summary = 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(summary.rulesEvaluated).toBeGreaterThan(0);
        expect(summary.errors).toBe(0);
      }
    );
  });
});
```

---

# 20. Test 08 · Idempotencia

```ts
// 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);
      }
    );
  });
});
```

---

# 21. Test 09 · Actions + Evidence

```ts
// tests/09_actions_evidence.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("Actions and Evidence", () => {
  it("creates actions with required evidence metadata", async () => {
    await withTestDbContext(
      {
        companyId: COMPANY_ID,
        userId: USER_ID,
        roleCodes: ["integration_service", "faro_owner", "company_admin"]
      },
      async (client) => {
        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 result = await client.query(
          `
          SELECT action_code, evidence_required, closure_criteria, payload
          FROM faro.actions
          WHERE company_id = $1
            AND action_code IS NOT NULL
          `,
          [COMPANY_ID]
        );

        expect(result.rows.length).toBeGreaterThan(0);

        for (const row of result.rows) {
          expect(row.evidence_required).toBe(true);
          expect(row.closure_criteria).toBeTruthy();
          expect(row.payload.evidence_required_codes?.length).toBeGreaterThan(0);
        }
      }
    );
  });
});
```

---

# 22. Test 10 · RLS multiempresa

```ts
// 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();
    }
  });
});
```

---

# 23. Test 11 · Demo regression

```ts
// tests/11_demo_regression.test.ts
import { describe, expect, it } from "vitest";
import fs from "node:fs";
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";

const expected = JSON.parse(
  fs.readFileSync("src/fixtures/expected/demo_expected_tensions.json", "utf8")
);

describe("Demo Regression", () => {
  it("generates expected MVP tensions for Empresa Demo", async () => {
    await withTestDbContext(
      {
        companyId: COMPANY_ID,
        userId: USER_ID,
        roleCodes: ["integration_service", "faro_owner", "company_admin"]
      },
      async (client) => {
        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 result = await client.query(
          `
          SELECT tension_code, title, severity, priority_score
          FROM faro.tensions
          WHERE company_id = $1
          `,
          [COMPANY_ID]
        );

        const byCode = new Map(result.rows.map((row) => [row.tension_code, row]));

        for (const item of expected) {
          if (!item.should_trigger) continue;

          const row = byCode.get(item.tension_code);

          expect(row).toBeTruthy();
          expect(row.severity).toBe(item.severity);

          if (item.minimum_priority_score) {
            expect(Number(row.priority_score)).toBeGreaterThanOrEqual(item.minimum_priority_score);
          }
        }
      }
    );
  });
});
```

---

# 24. Test 12 · Catalog consistency

Este test es clave por la inconsistencia posible entre seed, YAML y motor.

```ts
// tests/12_catalog_consistency.test.ts
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import YAML from "yaml";
import { glob } from "glob";

const CANONICAL_TENSIONS = [
  "TNS-001",
  "TNS-002",
  "TNS-003",
  "TNS-004",
  "TNS-005",
  "TNS-006",
  "TNS-007",
  "TNS-008",
  "TNS-009",
  "TNS-010",
  "TNS-011",
  "TNS-012",
  "TNS-013",
  "TNS-014",
  "TNS-015",
  "TNS-016",
  "TNS-017",
  "TNS-018",
  "TNS-019",
  "TNS-020",
  "TNS-021",
  "TNS-022",
  "TNS-023",
  "TNS-024",
  "TNS-025",
  "TNS-026",
  "TNS-027",
  "TNS-028",
  "TNS-029",
  "TNS-030"
];

describe("Catalog Consistency", () => {
  it("all YAML rules use canonical tension codes", async () => {
    const files = await glob("rules/**/*.yaml");

    for (const file of files) {
      const raw = fs.readFileSync(file, "utf8");
      const parsed = YAML.parse(raw);

      expect(CANONICAL_TENSIONS).toContain(parsed.tension_code);
    }
  });

  it("rule_code number matches tension_code number", async () => {
    const files = await glob("rules/**/*.yaml");

    for (const file of files) {
      const raw = fs.readFileSync(file, "utf8");
      const parsed = YAML.parse(raw);

      const ruleNumber = parsed.rule_code.replace("RULE-TNS-", "");
      const tensionNumber = parsed.tension_code.replace("TNS-", "");

      expect(ruleNumber).toBe(tensionNumber);
    }
  });
});
```

---

# 25. Tests SQL de control rápido

## 25.1 Reglas activas

```sql
SELECT
  rule_code,
  name,
  status,
  version
FROM faro.rule_definitions
WHERE status = 'active'
ORDER BY rule_code;
```

## 25.2 Evaluaciones sin error

```sql
SELECT
  status,
  COUNT(*) AS total
FROM faro.rule_evaluations
WHERE company_id = '10000000-0000-0000-0000-000000000001'
GROUP BY status;
```

Esperado:

```text
completed > 0
failed = 0
```

## 25.3 Tensiones sin responsable

```sql
SELECT
  tension_code,
  title
FROM faro.tensions
WHERE company_id = '10000000-0000-0000-0000-000000000001'
  AND responsible_user_id IS NULL;
```

Esperado MVP:

```text
0 filas
```

Si devuelve filas, el mapping de roles falló.

## 25.4 Acciones sin evidencia requerida

```sql
SELECT
  action_code,
  title
FROM faro.actions
WHERE company_id = '10000000-0000-0000-0000-000000000001'
  AND evidence_required = false;
```

Esperado:

```text
0 filas
```

## 25.5 Acciones sin criterio de cierre

```sql
SELECT
  action_code,
  title
FROM faro.actions
WHERE company_id = '10000000-0000-0000-0000-000000000001'
  AND (
    closure_criteria IS NULL
    OR trim(closure_criteria) = ''
  );
```

Esperado:

```text
0 filas
```

---

# 26. Matriz de casos mínimos por regla

Cada regla MVP debe tener al menos estos casos:

| Caso              | Descripción                        | Resultado esperado      |
| ----------------- | ---------------------------------- | ----------------------- |
| Positivo base     | Cumple condiciones mínimas         | Dispara                 |
| Negativo base     | Falta una condición                | No dispara              |
| Baja confianza    | KPIs con confianza menor al umbral | No dispara o warning    |
| KPI faltante      | Falta KPI requerido                | No dispara              |
| Severidad default | Cumple sin escalamiento            | Severidad default       |
| Severidad crítica | Cumple escalamiento                | Severidad crítica       |
| Acción            | Genera acción esperada             | Acción creada           |
| Evidencia         | Exige evidencia esperada           | Evidencia requerida     |
| Idempotencia      | Corre dos veces                    | No duplica              |
| Score impact      | Calcula impacto                    | Impacto dentro de rango |

---

# 27. Tabla de cobertura MVP recomendada

| Regla   | Positivo | Negativo | Confianza | Faltante | Severidad | Acción | Evidencia | Idempotencia |
| ------- | -------: | -------: | --------: | -------: | --------: | -----: | --------: | -----------: |
| TNS-001 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-002 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-003 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-004 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-005 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-006 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-007 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-008 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-009 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |
| TNS-010 |       Sí |       Sí |        Sí |       Sí |        Sí |     Sí |        Sí |           Sí |

Para las reglas 11 a 30, al menos:

```text
positivo + negativo + evidencia + acción
```

---

# 28. Expected JSON por regla

Cada regla debería tener un archivo expected:

```text
expected/
  RULE-TNS-001.expected.json
  RULE-TNS-002.expected.json
  ...
```

Ejemplo:

```json
{
  "rule_code": "RULE-TNS-001",
  "tension_code": "TNS-001",
  "expected": {
    "triggered": true,
    "severity": "critical",
    "minimum_priority_score": 90,
    "score_impact": {
      "min": -12,
      "max": -8
    },
    "actions": [
      "ACT-COM-001",
      "ACT-COM-002",
      "ACT-COM-003"
    ],
    "evidence": [
      "EVD-007",
      "EVD-012"
    ]
  }
}
```

---

# 29. Test anti-humo

Este test es conceptual pero útil: ninguna tensión puede crearse sin diagnóstico, responsable, acción y evidencia.

```sql
SELECT
  t.tension_code,
  t.title
FROM faro.tensions t
LEFT JOIN faro.actions a
  ON a.tension_id = t.tension_id
WHERE t.company_id = '10000000-0000-0000-0000-000000000001'
  AND t.status NOT IN ('closed', 'rejected')
GROUP BY t.tension_code, t.title
HAVING COUNT(a.action_id) = 0;
```

Esperado:

```text
0 filas
```

Frase de gobierno:

```text
Una tensión sin acción es una queja elegante.
FARO no debería producir quejas elegantes.
```

---

# 30. Test de falsos positivos

Ejemplo para TNS-001:

```json
{
  "name": "no_dispara_si_ventas_crecen_y_margen_tambien",
  "input": {
    "KPI-SAL-001": {
      "delta_pct": 0.18,
      "confidence_score": 90
    },
    "KPI-SAL-002": {
      "value": 0.31,
      "delta_pct": 0.10,
      "confidence_score": 88
    },
    "KPI-SAL-003": {
      "value": 0.07,
      "delta_pct": 0.05,
      "confidence_score": 86
    }
  },
  "expect": {
    "triggered": false
  }
}
```

Esto valida que FARO no castigue crecimiento sano.

---

# 31. Test de falsos negativos

Ejemplo para TNS-001:

```json
{
  "name": "dispara_cuando_todas_las_senales_son_criticas",
  "input": {
    "KPI-SAL-001": {
      "delta_pct": 0.25,
      "confidence_score": 92
    },
    "KPI-SAL-002": {
      "value": 0.18,
      "delta_pct": -0.35,
      "confidence_score": 91
    },
    "KPI-SAL-003": {
      "value": 0.16,
      "delta_pct": 1.20,
      "confidence_score": 90
    }
  },
  "expect": {
    "triggered": true,
    "severity": "critical"
  }
}
```

Esto valida que FARO no se duerma cuando el problema es obvio.

---

# 32. Tests de datos faltantes

| Política                      | Dato faltante        | Resultado           |
| ----------------------------- | -------------------- | ------------------- |
| `do_not_trigger`              | Falta KPI requerido  | No dispara          |
| `trigger_with_warning`        | Falta KPI secundario | Dispara con warning |
| `create_data_quality_tension` | Falta fuente crítica | Genera tensión DQ   |
| `manual_review`               | Falta dato sensible  | Enviar a revisión   |

Ejemplo:

```ts
it("does not trigger when missing KPI and policy is do_not_trigger", () => {
  const result = evaluateRule({
    rule,
    snapshots: {
      "KPI-SAL-001": { deltaPct: 0.18, confidenceScore: 90 }
    } as any
  });

  expect(result.triggered).toBe(false);
  expect(result.missingKpis).toContain("KPI-SAL-002");
});
```

---

# 33. Tests de severidad

| Caso                   | Resultado                   |
| ---------------------- | --------------------------- |
| Cumple condición base  | `high`                      |
| Cumple escalamiento    | `critical`                  |
| No cumple escalamiento | Mantiene default            |
| Baja confianza         | Baja prioridad o no dispara |
| Impacto Score alto     | Priority sube               |

---

# 34. Tests de acciones

Cada acción generada debe validar:

| Campo                             |   Requerido |
| --------------------------------- | ----------: |
| `action_code`                     |          Sí |
| `title`                           |          Sí |
| `description`                     |          Sí |
| `responsible_user_id`             |          Sí |
| `approver_user_id`                | Recomendado |
| `due_date`                        |          Sí |
| `evidence_required`               |          Sí |
| `closure_criteria`                |          Sí |
| `payload.evidence_required_codes` |          Sí |

---

# 35. Tests de evidencia

Toda tensión disparada debe tener evidencia esperada en:

```text
tension.payload.evidence_required
action.payload.evidence_required_codes
```

SQL:

```sql
SELECT
  tension_code,
  payload->'evidence_required' AS evidence_required
FROM faro.tensions
WHERE company_id = '10000000-0000-0000-0000-000000000001';
```

Esperado:

```text
Cada tensión tiene array no vacío.
```

---

# 36. Tests de Score impact

Toda tensión disparada debe tener:

```text
score_impact < 0
score_impact >= max negativo permitido
score_impact coherente con severidad
```

Ejemplo:

```ts
expect(result.scoreImpact).toBeLessThan(0);
expect(result.scoreImpact).toBeGreaterThanOrEqual(-15);
```

---

# 37. Tests de RLS negativos obligatorios

## Caso 1 · Sin contexto

```text
No debe ver datos.
```

## Caso 2 · Tenant A consulta Tenant B

```text
Debe devolver 0 filas.
```

## Caso 3 · Tenant A intenta insertar company_id Tenant B

```text
Debe fallar por RLS.
```

## Caso 4 · Usuario sin rol intenta leer RAW

```text
Debe devolver 0 filas o error.
```

## Caso 5 · IA/export/reporte sin contexto

```text
Debe fallar.
```

---

# 38. Tests de catálogo canónico

Validar:

1. `rule_code` coincide con `tension_code`.
2. `tension_code` existe en catálogo oficial.
3. `recommended_actions` existen en biblioteca oficial.
4. `evidence_required` existe en catálogo oficial.
5. `assign_to_role` existe en role mapping.
6. No hay reglas duplicadas activas con misma prioridad sin versionado.
7. No hay códigos TNS usados en seed pero inexistentes en YAML.
8. No hay códigos TNS usados en reporte pero inexistentes en catálogo.

---

# 39. GitHub Actions CI

```yaml
name: FARO MVP Rules Tests

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

jobs:
  rules-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 demo
        run: npm run db:seed:demo

      - name: Validate YAML rules
        run: npm run validate

      - name: Test rules
        run: npm run test:rules

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

---

# 40. Comandos operativos

```bash
# 1. Validar tipos
npm run typecheck

# 2. Validar YAML
npm run validate

# 3. Testear reglas embebidas en YAML
npm run test:rules

# 4. Correr unit tests
npm run test:unit

# 5. Correr integración
npm run test:integration

# 6. Correr regresión demo
npm run test:demo

# 7. Correr todo
npm run test
```

---

# 41. Criterios de aceptación FARO-TEST-002

FARO-TEST-002 se considera aceptado si:

| Criterio                                | Estado esperado |
| --------------------------------------- | --------------- |
| Valida schema YAML                      | Sí              |
| Rechaza YAML inválido                   | Sí              |
| Rechaza operador inválido               | Sí              |
| Rechaza KPI inexistente                 | Sí              |
| Rechaza acción inexistente              | Sí              |
| Rechaza rol inexistente                 | Sí              |
| Rechaza evidencia inexistente           | Sí              |
| Evalúa condiciones `all`                | Sí              |
| Evalúa condiciones `any`                | Sí              |
| Evalúa condiciones `none`               | Sí              |
| Valida confianza mínima                 | Sí              |
| Valida datos faltantes                  | Sí              |
| Calcula severidad                       | Sí              |
| Calcula Score impact                    | Sí              |
| Genera tensión esperada                 | Sí              |
| Genera acción esperada                  | Sí              |
| Exige evidencia                         | Sí              |
| No duplica tensiones                    | Sí              |
| No duplica acciones                     | Sí              |
| Respeta RLS                             | Sí              |
| Dataset demo genera tensiones esperadas | Sí              |
| CI bloquea cambios defectuosos          | Sí              |

---

# 42. Criterios de rechazo

Debe rechazarse si ocurre cualquiera de estos casos:

| Caso                                  | Severidad  |
| ------------------------------------- | ---------- |
| Regla inválida pasa validación        | Crítica    |
| KPI inexistente no falla              | Alta       |
| Acción sin evidencia pasa             | Alta       |
| Motor duplica tensiones abiertas      | Alta       |
| Tenant A ve Tenant B                  | Crítica    |
| Sin contexto ve datos                 | Crítica    |
| Tensión crítica queda sin responsable | Alta       |
| Tensión queda sin acción              | Alta       |
| Acción queda sin criterio de cierre   | Alta       |
| Score impact positivo en tensión      | Media/Alta |
| Dataset demo no dispara TNS-001       | Alta       |
| CI no ejecuta tests                   | Alta       |

---

# 43. Output esperado del test suite

```text
✓ 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

Tests: 12 passed
Errors: 0
Warnings: 0
```

---

# 44. Resultado ejecutivo

Cuando FARO-TEST-002 esté implementado, se podrá decir con respaldo:

```text
Las reglas MVP no son solo documentación.
Están validadas, testeadas y protegidas contra errores críticos.
El motor puede correr sin duplicar tensiones.
Las acciones salen con responsable y evidencia.
La base respeta aislamiento multiempresa.
La demo tiene resultados esperados verificables.
```

Eso cambia completamente la conversación con un socio técnico.

Sin tests:

```text
“Creemos que funciona.”
```

Con tests:

```text
“Corre, valida, falla cuando debe fallar y genera el resultado esperado.”
```

Hay bastante diferencia. Una vende humo; la otra vende sistema.

---

# 45. Próximo paso recomendado

Después de FARO-TEST-002 corresponde construir:

## FARO-UI-001 · Bandeja de Tensiones MVP

Objetivo:

Crear la primera pantalla operativa donde el usuario vea:

* tensiones activas;
* severidad;
* prioridad;
* responsable;
* estado;
* acciones sugeridas;
* evidencia requerida;
* impacto en Score;
* explicación de por qué se disparó.

Sin esta UI, el motor funciona, pero el usuario no lo ve. Y en demo, lo que no se ve, no existe.
