Funciones puras
Condiciones, severidad, score impact, confianza. Sin DB, sin mocks complejos. Fallar acá es bug evidente.
VitestConsolida FARO-TEST-002 (reglas YAML, parser, motor, integración) y FARO-TEST-002.1 (alineación con catálogos canónicos). Vitest + PostgreSQL 15 sobre el módulo faro-tests/, con CI bloqueante en pull request y validación de la cadena rule_definitions → tension_definitions → action_definitions → evidence_definitions.
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_definitions → tension_definitions → action_definitions → evidence_definitions → motor → tensions → actions → evidence requirements → reports → score
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.
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.”
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.
Condiciones, severidad, score impact, confianza. Sin DB, sin mocks complejos. Fallar acá es bug evidente.
VitestRechaza operadores no soportados, KPIs desconocidos, ausencia de rule_code, evidencia vacía.
Cada regla cumple el contrato JSON Schema y referencia códigos del catálogo canónico (TNS/ACT/EVD).
Vitest + AjvEvalúa reglas usando snapshots JSON, sin DB. Valida triggered/severidad/priority/scoreImpact.
VitestCorre runEvaluation contra DB de test, valida que crea tensions y actions reales.
Aislamiento multiempresa. Sin contexto: 0 filas. Con contexto A: solo datos de A. Errores acá son críticos.
SQL + VitestCorre el motor dos veces sobre el mismo período. Segunda corrida: 0 creaciones, ≥1 actualización.
DB testEmpresa Demo Cuyo dispara las tensiones esperadas con severidad y priority score sobre el umbral.
Expected JSONGitHub Actions con PostgreSQL service, migrations + seeds + tests. Bloqueante para merge a main.
GitHub ActionsLos 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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
Toda la suite vive en un módulo independiente faro-tests/. Helpers para contexto RLS, aserciones de catálogo y motor, fixtures por nivel.
Ambiente Node, timeouts 30s (motor + DB), tests secuenciales (no concurrentes) para evitar colisiones de RLS context.
withTestDbContext abre transacción, hace set_config('app.company_id', ...), ejecuta el callback, y rollback final. Limpia sin ensuciar la base.
expectNoRows(client, sql), expectRowCount(client, sql, n), getScalarNumber(). Producen errores con cuerpo de filas para diagnóstico inmediato.
assertTensionsHaveCanonicalPayload(client, companyId) y assertActionsHaveCanonicalPayload(). Verifican que el payload JSONB tenga las claves canónicas obligatorias.
Diccionario con queries de control: activeTensionsCount, rulesWithoutCanonicalTension, tensionActionsWithoutCatalog, actionEvidenceWithoutCatalog, etc.
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).
DATABASE_URL, dos FARO_TEST_COMPANY_* y dos FARO_TEST_USER_* para los tests RLS multiempresa. Roles default: integration_service, faro_owner, company_admin.
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).
import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, environment: "node", include: ["tests/**/*.test.ts"], testTimeout: 30000, hookTimeout: 30000, sequence: { concurrent: false } } });
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(); } }
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 ` };
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.
{
"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"]
}
]
}
{
"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.
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.
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); }); });
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); }); });
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); } ); }); });
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(); } }); });
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"); }); });
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.
Los tres catálogos deben tener exactamente 30 tensiones, 30 acciones y 12 evidencias activas. Cualquier desvío rompe los tests 14 y 15.
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';
Si una regla activa referencia un tension_code no presente en el catálogo canónico, hay un huérfano. Bloqueador inmediato para deploy.
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;
Toda recommended_actions[*] de tension_definitions debe resolver en action_definitions. Si no, el motor crea acciones huérfanas sin evidencia ni SLA.
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;
Toda evidence_required_codes[*] de action_definitions debe existir en evidence_definitions. Sin evidencia válida no hay cierre confiable.
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;
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.
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;
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.
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
Si saltás este orden, los tests fallan por dependencia, no por bug. Mejor error temprano que demo rota con sonrisa.
Schema faro + tablas core (companies, users, kpi_snapshots, tensions, actions, rule_definitions, rule_evaluations).
Políticas RLS por company_id, roles operativos y configuración app.company_id / app.user_id / app.role_codes.
Inserta Empresa Demo Cuyo S.A. con UUID fijo, usuarios técnicos y KPIs snapshot del período 2026-05-01 / 2026-05-31.
DDL tension_definitions + seed con las 30 fichas canónicas TNS-001..TNS-030 (ver catalogo-tensiones-mvp.html).
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.
DDL action_definitions + seed con 30 acciones canónicas (ver catalogo-acciones-mvp.html).
DDL evidence_definitions + seed con 12 evidencias canónicas y reglas de cierre (ver catalogo-evidencias-mvp.html).
Parser DSL de reglas YAML con validación de schema, KPIs requeridos, operadores y referencias al catálogo (ver reglas-mvp-yaml.html).
Motor evaluador (ver motor-evaluador-mvp.html) + patch que enriquece payload con los tres catálogos canónicos.
npm run test:unit && npm run test:integration && npm run test:demo && npm run test:catalog. Cubre tests/01..12.
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.
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.
tension_definitionsaction_definitionsevidence_definitionsRULE-TNS-NNN coincide con TNS-NNNclosure_criteriadefault_sla_daysACTION_CATALOG hardcodeadoFrase 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.
Los tests no validan documentos: validan piezas de producto. Cada referencia siguiente es algo concreto que la suite verifica.
El motor que recibe condiciones, calcula severidad, idempotencia y enriquece con catálogos. Tests 06/07/08/16 lo cubren.
DSL de reglas YAML + parser. Tests 01/02/03/12/13 validan schema, operadores, KPIs y alineación de tension_code.
30 fichas canónicas TNS-001..TNS-030. Tests 12/14/17 garantizan integridad referencial y conteo exacto.
30 acciones canónicas. Tests 14/15 validan que las recommended_actions de tensiones resuelvan acá.
12 evidencias canónicas con trust_level. Tests 15/19 validan integridad y reglas de cierre por evidencia.
Estrategia QA paraguas que extiende esta suite con UI tests (Playwright), performance y carga. En construcción.
Spec de ambientes (dev / staging / demo / pre / prod) donde corren los seeds y se ejecutan las suites canónicas. En construcción.
Políticas RLS multiempresa y configuración de set_config('app.company_id', ...). Tests 10 verifican el aislamiento real.
Workflow de escalamiento de severidad. Tests 05 validan el calculator default → critical con condiciones de escalación.
FARO-TEST-002 + 002.1 cubren reglas, motor, integración, RLS, idempotencia, regresión demo y alineación de catálogos. Próximo paso operativo: bandeja de tensiones (FARO-UI-001) consumiendo el payload canónico verificado.
→ Volver al hub modelos NDA