Files
FRASA-CAS/api-gateway/src/services/sync-monday.service.test.ts
Consult AS Technology 60649890eb feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes

Env MODO_LOCAL_FORZADO=true para testing sin Monday.
2026-09-06 08:08:56 +00:00

226 lines
8.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { AppError } from '../utils/errors.js'
import type { StateStore } from './automatizaciones.service.js'
import type { MondayService } from './monday.service.js'
import {
claveSync,
esFalloMonday,
MAX_INTENTOS_SYNC,
SyncMondayService,
type SyncRegistro,
type SyncStore,
} from './sync-monday.service.js'
// ---------- Fixtures ----------
let consecutivo = 0
function registro(parcial: Partial<SyncRegistro> = {}): SyncRegistro {
consecutivo += 1
return {
id: parcial.id ?? `reg-${consecutivo}`,
tipo: 'CREAR_LEAD',
payloadJson: JSON.stringify({ nombre: 'Lead de prueba' }),
referenciaLocal: null,
estado: 'PENDIENTE',
intentos: 0,
// createdAt creciente: el orden del arreglo ES el orden FIFO
createdAt: new Date(Date.UTC(2026, 8, 5, 12, 0, consecutivo)),
...parcial,
}
}
/** StateStore en memoria (mismo patrón que automatizaciones.service.test.ts) */
function stateEnMemoria(): StateStore & { mapa: Map<string, string> } {
const mapa = new Map<string, string>()
return {
mapa,
async get(clave) {
return mapa.get(clave) ?? null
},
async set(clave, valor) {
mapa.set(clave, valor)
},
}
}
/** SyncStore en memoria: guarda los registros y el historial de marcados */
function storeEnMemoria(registros: SyncRegistro[]) {
const rows = new Map(registros.map((r) => [r.id, { ...r }]))
const marcados: { id: string; datos: { estado: string; intentos?: number; ultimoError?: string | null } }[] = []
const store: SyncStore & { rows: Map<string, SyncRegistro>; marcados: typeof marcados } = {
rows,
marcados,
async pendientes(limite) {
return [...rows.values()]
.filter((r) => r.estado === 'PENDIENTE')
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, limite)
},
async marcar(id, datos) {
marcados.push({ id, datos })
const row = rows.get(id)
if (!row) throw new Error(`Registro inexistente: ${id}`)
row.estado = datos.estado
if (datos.intentos !== undefined) row.intentos = datos.intentos
if (datos.ultimoError !== undefined) row.ultimoError = datos.ultimoError
},
}
return store
}
const saludSana = { estaDisponible: () => true }
const saludCaida = { estaDisponible: () => false }
// En fase A no hay handlers por defecto; monday solo se pasa al contexto
const mondayDummy = {} as MondayService
function crearServicio(store: SyncStore, state: StateStore, salud = saludSana) {
return new SyncMondayService(mondayDummy, state, store, salud)
}
// ---------- Tests ----------
describe('esFalloMonday', () => {
it('solo los AppError 502 cuentan como fallo de Monday', () => {
expect(esFalloMonday(new AppError(502, 'Monday no respondió'))).toBe(true)
expect(esFalloMonday(new AppError(500, 'otro'))).toBe(false)
expect(esFalloMonday(new Error('x'))).toBe(false)
})
})
describe('SyncMondayService.procesarPendientes', () => {
it('no-op cuando Monday no está disponible (no toca la cola)', async () => {
const store = storeEnMemoria([registro()])
const state = stateEnMemoria()
const svc = crearServicio(store, state, saludCaida)
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 0 })
expect(store.marcados).toHaveLength(0)
expect(store.rows.get('reg-1')?.estado).toBe('PENDIENTE')
})
it('procesa FIFO y marca HECHO tras ejecutar el handler', async () => {
const store = storeEnMemoria([
registro({ id: 'a', createdAt: new Date('2026-09-05T12:00:02Z') }),
registro({ id: 'b', createdAt: new Date('2026-09-05T12:00:01Z') }),
])
const state = stateEnMemoria()
const svc = crearServicio(store, state)
const orden: string[] = []
svc.registrarHandler('CREAR_LEAD', async (payload) => {
orden.push(String(payload.orden))
return 'item-1'
})
// Reescribo payloads para distinguir orden de ejecución
store.rows.get('a')!.payloadJson = JSON.stringify({ orden: 'segundo-en-fila' })
store.rows.get('b')!.payloadJson = JSON.stringify({ orden: 'primero-en-fila' })
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 2, fallos: 0 })
expect(orden).toEqual(['primero-en-fila', 'segundo-en-fila']) // b (más viejo) primero
expect(store.rows.get('a')?.estado).toBe('HECHO')
expect(store.rows.get('b')?.estado).toBe('HECHO')
// Dedup persistido con el id devuelto por el handler
expect(state.mapa.get(claveSync('a'))).toBe('item-1')
expect(state.mapa.get(claveSync('b'))).toBe('item-1')
})
it('marca ERROR con mensaje claro cuando el tipo no tiene handler', async () => {
const store = storeEnMemoria([registro({ id: 'x', tipo: 'DESCARTE' })])
const svc = crearServicio(store, stateEnMemoria())
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.ultimoError).toMatch(/sin handler registrado: "DESCARTE"/)
})
it('marca ERROR cuando el payloadJson no es JSON válido', async () => {
const store = storeEnMemoria([registro({ id: 'x', payloadJson: '{roto' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes()
expect(r.fallos).toBe(1)
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payloadJson inválido/)
})
it('fallo Monday (502): vuelve a PENDIENTE con intentos+1 (backoff)', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: 2 })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió tras 4 intentos')
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 0, fallos: 1 })
const row = store.rows.get('x')
expect(row?.estado).toBe('PENDIENTE')
expect(row?.intentos).toBe(3)
expect(row?.ultimoError).toMatch(/Monday no respondió/)
})
it('al superar MAX_INTENTOS_SYNC el fallo Monday queda ERROR definitivo', async () => {
const store = storeEnMemoria([registro({ id: 'x', intentos: MAX_INTENTOS_SYNC })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new AppError(502, 'Monday no respondió')
})
await svc.procesarPendientes()
const row = store.rows.get('x')
expect(row?.estado).toBe('ERROR')
expect(row?.intentos).toBe(MAX_INTENTOS_SYNC + 1)
})
it('un error que NO es de Monday marca ERROR sin reintento', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {
throw new Error('payload incompleto: falta nombre')
})
await svc.procesarPendientes()
expect(store.rows.get('x')?.estado).toBe('ERROR')
expect(store.rows.get('x')?.ultimoError).toMatch(/payload incompleto/)
})
it('idempotencia: con sync:<id> ya en el state no reejecuta el handler', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const state = stateEnMemoria()
state.mapa.set(claveSync('x'), 'item-ya-creado')
const svc = crearServicio(store, state)
let llamadas = 0
svc.registrarHandler('CREAR_LEAD', async () => {
llamadas++
})
const r = await svc.procesarPendientes()
expect(r).toEqual({ procesados: 1, fallos: 0 })
expect(llamadas).toBe(0) // no duplica el item en Monday
expect(store.rows.get('x')?.estado).toBe('HECHO')
})
it('respeta el límite por pasada', async () => {
const store = storeEnMemoria([registro({ id: 'a' }), registro({ id: 'b' }), registro({ id: 'c' })])
const svc = crearServicio(store, stateEnMemoria())
svc.registrarHandler('CREAR_LEAD', async () => {})
const r = await svc.procesarPendientes({ limite: 2 })
expect(r.procesados).toBe(2)
expect(store.rows.get('c')?.estado).toBe('PENDIENTE')
})
it('una pasada en curso bloquea la siguiente (mutex)', async () => {
const store = storeEnMemoria([registro({ id: 'x' })])
const svc = crearServicio(store, stateEnMemoria())
let liberar!: () => void
const bloqueo = new Promise<void>((res) => {
liberar = res
})
svc.registrarHandler('CREAR_LEAD', async () => {
await bloqueo
})
const pasada1 = svc.procesarPendientes()
// Segunda pasada mientras la primera sigue: no-op inmediato
await expect(svc.procesarPendientes()).resolves.toEqual({ procesados: 0, fallos: 0 })
liberar()
await expect(pasada1).resolves.toEqual({ procesados: 1, fallos: 0 })
})
})