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.
This commit is contained in:
225
api-gateway/src/services/sync-monday.service.test.ts
Normal file
225
api-gateway/src/services/sync-monday.service.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user