feat(api): automatizaciones Monday — round-robin de leads, recordatorio inactividad 10d, webhooks reales y polling cron
- Servicio de automatizaciones con estado persistido en SQLite (índice RR, dedup de recordatorios) - Webhook Monday procesa create_item del board Leads y asigna vendedor - Crons: polling cada 5 min (respaldo sin webhook público) + escaneo diario 9am - Endpoints admin: /api/automatizaciones/estado, /round-robin/run, /inactividad/preview - 15 tests nuevos (70/70 verdes)
This commit is contained in:
240
api-gateway/src/services/automatizaciones.service.test.ts
Normal file
240
api-gateway/src/services/automatizaciones.service.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AutomatizacionesService,
|
||||
claveRecordatorio,
|
||||
esEstadoTerminal,
|
||||
leadsInactivos,
|
||||
parseRrUserIds,
|
||||
vendedorEnTurno,
|
||||
type StateStore,
|
||||
} from './automatizaciones.service.js'
|
||||
import type { LeadMonday, UsuarioMonday } from './monday.service.js'
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
const VENDEDORES: UsuarioMonday[] = [
|
||||
{ id: 101, nombre: 'Ana', email: 'ana@x.com' },
|
||||
{ id: 102, nombre: 'Beto', email: 'beto@x.com' },
|
||||
{ id: 103, nombre: 'Carla', email: 'carla@x.com' },
|
||||
]
|
||||
|
||||
function lead(id: string, overrides: Partial<LeadMonday> = {}): LeadMonday {
|
||||
return {
|
||||
id,
|
||||
nombre: `Lead ${id}`,
|
||||
updatedAt: '2026-08-01T10:00:00Z',
|
||||
estado: 'NUEVO',
|
||||
vendedorIds: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** StateStore en memoria para tests (sin Prisma) */
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Monday mockeado: leads fijos, registra asignaciones y updates */
|
||||
function mondayMock(leads: LeadMonday[]) {
|
||||
return {
|
||||
asignaciones: [] as { itemId: string; userId: number }[],
|
||||
updates: [] as { itemId: string; body: string }[],
|
||||
async getLeads() {
|
||||
return leads
|
||||
},
|
||||
async asignarVendedorALead(itemId: string, userId: number) {
|
||||
this.asignaciones.push({ itemId, userId })
|
||||
},
|
||||
async crearUpdateEnItem(itemId: string, body: string) {
|
||||
this.updates.push({ itemId, body })
|
||||
return 'upd-1'
|
||||
},
|
||||
async getUsuariosWorkspace() {
|
||||
return VENDEDORES
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const crearServicio = (monday: ReturnType<typeof mondayMock>, state: StateStore) =>
|
||||
new AutomatizacionesService(monday, state)
|
||||
|
||||
// ---------- Lógica pura ----------
|
||||
|
||||
describe('parseRrUserIds', () => {
|
||||
it('parsea IDs separados por coma ignorando espacios y vacíos', () => {
|
||||
expect(parseRrUserIds(' 106735975, 106735976 ,, ')).toEqual([
|
||||
{ id: 106735975, nombre: 'Usuario 106735975', email: '' },
|
||||
{ id: 106735976, nombre: 'Usuario 106735976', email: '' },
|
||||
])
|
||||
expect(parseRrUserIds('')).toEqual([])
|
||||
})
|
||||
|
||||
it('lanza error con IDs inválidos', () => {
|
||||
expect(() => parseRrUserIds('123,abc')).toThrow(/inválido/)
|
||||
expect(() => parseRrUserIds('-5')).toThrow(/inválido/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('esEstadoTerminal', () => {
|
||||
it('reconoce DESCARTADO sin importar mayúsculas ni espacios', () => {
|
||||
expect(esEstadoTerminal('DESCARTADO')).toBe(true)
|
||||
expect(esEstadoTerminal(' descartado ')).toBe(true)
|
||||
expect(esEstadoTerminal('NUEVO')).toBe(false)
|
||||
expect(esEstadoTerminal('CONTACTADO')).toBe(false)
|
||||
expect(esEstadoTerminal('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vendedorEnTurno (rotación round-robin)', () => {
|
||||
it('rota circularmente por la lista', () => {
|
||||
expect(vendedorEnTurno(VENDEDORES, 0)?.id).toBe(101)
|
||||
expect(vendedorEnTurno(VENDEDORES, 1)?.id).toBe(102)
|
||||
expect(vendedorEnTurno(VENDEDORES, 2)?.id).toBe(103)
|
||||
expect(vendedorEnTurno(VENDEDORES, 3)?.id).toBe(101) // wraparound
|
||||
expect(vendedorEnTurno(VENDEDORES, 7)?.id).toBe(102)
|
||||
})
|
||||
|
||||
it('devuelve undefined con lista vacía', () => {
|
||||
expect(vendedorEnTurno([], 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('leadsInactivos', () => {
|
||||
const ahora = new Date('2026-08-22T00:00:00Z')
|
||||
|
||||
it('incluye leads activos que superan el umbral de días', () => {
|
||||
const leads = [
|
||||
lead('1', { updatedAt: '2026-08-10T00:00:00Z' }), // 12 días → inactivo (umbral 10)
|
||||
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // 2 días → activo
|
||||
lead('3', { updatedAt: '2026-07-01T00:00:00Z', estado: 'DESCARTADO' }), // terminal → excluido
|
||||
]
|
||||
const inactivos = leadsInactivos(leads, ahora, 10)
|
||||
expect(inactivos.map((l) => l.id)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('un lead actualizado hace exactamente N días aún no supera el umbral', () => {
|
||||
const leads = [lead('1', { updatedAt: '2026-08-12T00:00:00Z' })] // justo 10 días
|
||||
expect(leadsInactivos(leads, ahora, 10)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignora fechas inválidas', () => {
|
||||
expect(leadsInactivos([lead('1', { updatedAt: 'no-es-fecha' })], ahora, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('claveRecordatorio (dedup por día)', () => {
|
||||
it('una clave por lead por día', () => {
|
||||
expect(claveRecordatorio('123', new Date('2026-08-22T09:00:00Z'))).toBe('recordatorio:123:2026-08-22')
|
||||
expect(claveRecordatorio('123', new Date('2026-08-22T23:59:00Z'))).toBe('recordatorio:123:2026-08-22')
|
||||
expect(claveRecordatorio('123', new Date('2026-08-23T00:00:00Z'))).toBe('recordatorio:123:2026-08-23')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Servicio (monday mockeado, sin red) ----------
|
||||
|
||||
describe('AutomatizacionesService.runRoundRobin', () => {
|
||||
it('asigna vendedores en rotación a leads sin persona y persiste el índice', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2'), lead('3'), lead('4')])
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r1 = await svc.runRoundRobin()
|
||||
expect(r1.asignados).toBe(4)
|
||||
expect(monday.asignaciones.map((a) => a.userId)).toEqual([101, 102, 103, 101])
|
||||
expect(state.mapa.get('rr:indice')).toBe('1') // 4 asignaciones % 3 vendedores
|
||||
|
||||
// Segunda pasada con un lead nuevo: continúa donde quedó (no reinicia en 0)
|
||||
const monday2 = mondayMock([lead('5')])
|
||||
const svc2 = new AutomatizacionesService(monday2, state)
|
||||
const r2 = await svc2.runRoundRobin()
|
||||
expect(r2.asignados).toBe(1)
|
||||
expect(monday2.asignaciones[0]?.userId).toBe(102)
|
||||
expect(state.mapa.get('rr:indice')).toBe('2')
|
||||
})
|
||||
|
||||
it('omite leads con vendedor ya asignado o en estado terminal', async () => {
|
||||
const monday = mondayMock([
|
||||
lead('1'),
|
||||
lead('2', { vendedorIds: [101] }), // ya asignado
|
||||
lead('3', { estado: 'DESCARTADO' }), // terminal
|
||||
])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin()
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '1', userId: 101 }])
|
||||
})
|
||||
|
||||
it('con soloItemId solo evalúa ese item (ruta del webhook create_item)', async () => {
|
||||
const monday = mondayMock([lead('1'), lead('2')])
|
||||
const svc = crearServicio(monday, stateEnMemoria())
|
||||
const r = await svc.runRoundRobin('2')
|
||||
expect(r.asignados).toBe(1)
|
||||
expect(monday.asignaciones).toEqual([{ itemId: '2', userId: 101 }])
|
||||
})
|
||||
|
||||
it('cachea los vendedores del workspace en el estado (una sola llamada)', async () => {
|
||||
const monday = mondayMock([])
|
||||
const spy = vi.spyOn(monday, 'getUsuariosWorkspace')
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
const v1 = await svc.obtenerVendedores()
|
||||
const v2 = await svc.obtenerVendedores()
|
||||
expect(v1.map((v) => v.id)).toEqual([101, 102, 103])
|
||||
expect(v2).toEqual(v1)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomatizacionesService.escaneoInactividad', () => {
|
||||
const ahora = new Date('2026-08-22T09:00:00Z')
|
||||
const leadsViejos = () => [
|
||||
lead('1', { updatedAt: '2026-08-01T00:00:00Z' }), // 21 días inactivo
|
||||
lead('2', { updatedAt: '2026-08-20T00:00:00Z' }), // activo
|
||||
]
|
||||
|
||||
it('preview: lista candidatos sin escribir en Monday ni en el estado', async () => {
|
||||
const monday = mondayMock(leadsViejos())
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r = await svc.escaneoInactividad({ preview: true, ahora })
|
||||
expect(r.preview).toBe(true)
|
||||
expect(r.candidatos.map((c) => c.itemId)).toEqual(['1'])
|
||||
expect(r.candidatos[0]?.diasInactivo).toBe(21)
|
||||
expect(r.recordatoriosEnviados).toBe(0)
|
||||
expect(monday.updates).toEqual([])
|
||||
expect(state.mapa.size).toBe(0) // preview no persiste nada
|
||||
})
|
||||
|
||||
it('ejecución real: crea update, marca dedup y no repite el mismo día', async () => {
|
||||
const monday = mondayMock(leadsViejos())
|
||||
const state = stateEnMemoria()
|
||||
const svc = crearServicio(monday, state)
|
||||
|
||||
const r1 = await svc.escaneoInactividad({ preview: false, ahora })
|
||||
expect(r1.recordatoriosEnviados).toBe(1)
|
||||
expect(monday.updates).toHaveLength(1)
|
||||
expect(monday.updates[0]?.body).toContain('21 días')
|
||||
expect(state.mapa.get('recordatorio:1:2026-08-22')).toBeDefined()
|
||||
|
||||
// Mismo día: no repite aunque el lead siga inactivo
|
||||
const r2 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-22T18:00:00Z') })
|
||||
expect(r2.recordatoriosEnviados).toBe(0)
|
||||
expect(r2.candidatos[0]?.yaRecordadoHoy).toBe(true)
|
||||
expect(monday.updates).toHaveLength(1)
|
||||
|
||||
// Al día siguiente vuelve a recordar
|
||||
const r3 = await svc.escaneoInactividad({ preview: false, ahora: new Date('2026-08-23T09:00:00Z') })
|
||||
expect(r3.recordatoriosEnviados).toBe(1)
|
||||
expect(monday.updates).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user