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 { 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 } { const mapa = new Map() 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; 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: 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((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 }) }) })