- 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.
226 lines
8.7 KiB
TypeScript
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 })
|
|
})
|
|
})
|