- POST /api/leads crea UN solo item en Oportunidades (Estado Afluencia) con todo el perfil (desarrollo, modelo, estado civil, credito, etc.) en las columnas del board; dedup por telefono contra Oportunidades; asignacion al capturista via columna Asesor o round-robin del equipo. - Round-robin, escalado e inactividad operan sobre Oportunidades (solo etapa Afluencia sin asesor entra al reparto). - Acciones del vendedor (contactado/cita/descartar) escriben directo en la oportunidad; descarte = Perdido + Motivo No Compra. - Mi dia y reportes leen Oportunidades; el funnel de /dash arranca en Afluencia; misOportunidades.porEtapa ya es real (columna Asesor). - El picker del alta busca en Leads (historico) Y Oportunidades, con insignia de origen; moverAAparto tolera ambos ids. - Lectura de status con value null = vacio (quirk del default Monday). - Script de migracion del historial de agosto Leads -> Oportunidades.
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { Request, Response } from 'express'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
runRoundRobin: vi.fn(),
|
|
escaneoOportunidades: vi.fn(),
|
|
moverAGrupo: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../services/automatizaciones.service.js', () => ({
|
|
automatizacionesService: {
|
|
runRoundRobin: mocks.runRoundRobin,
|
|
escaneoOportunidades: mocks.escaneoOportunidades,
|
|
},
|
|
}))
|
|
|
|
vi.mock('../services/grupos-estado.service.js', () => ({
|
|
// Leads (111) y Oportunidades (222) con mapeo; el resto de boards, sin mapeo
|
|
GRUPOS_POR_ESTADO: {
|
|
'111': { columna: 'Estado', destinos: { NUEVO: 'topics' } },
|
|
'222': { columna: 'Estado', destinos: { Ganado: 'group_mm6scqz3' } },
|
|
},
|
|
moverAGrupoSegunEstado: mocks.moverAGrupo,
|
|
}))
|
|
|
|
vi.mock('../config/index.js', () => ({
|
|
config: {
|
|
monday: {
|
|
boards: { leads: '111', oportunidades: '222' },
|
|
},
|
|
},
|
|
}))
|
|
|
|
import { webhookMonday } from './webhooks.controller.js'
|
|
|
|
function resFake(): { res: Response; json: ReturnType<typeof vi.fn>; sendStatus: ReturnType<typeof vi.fn> } {
|
|
const json = vi.fn()
|
|
const sendStatus = vi.fn()
|
|
return { res: { json, sendStatus } as unknown as Response, json, sendStatus }
|
|
}
|
|
|
|
const reqCon = (body: unknown): Request => ({ body } as Request)
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mocks.runRoundRobin.mockResolvedValue({ asignados: 1 })
|
|
mocks.escaneoOportunidades.mockResolvedValue({ apartadosCreados: ['x'], ventasCreadas: [], errores: [] })
|
|
mocks.moverAGrupo.mockResolvedValue({ movido: true, grupo: 'topics' })
|
|
})
|
|
|
|
describe('webhookMonday', () => {
|
|
it('responde el challenge del handshake tal cual', () => {
|
|
const { res, json, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ challenge: 'abc-123' }), res)
|
|
expect(json).toHaveBeenCalledWith({ challenge: 'abc-123' })
|
|
expect(sendStatus).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignora payloads sin estructura de evento (200 sin disparar nada)', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ foo: 'bar' }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
|
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('create_item en Oportunidades dispara el round-robin del item', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 999 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.runRoundRobin).toHaveBeenCalledWith('999')
|
|
})
|
|
|
|
it('create_pulse (nombre real del evento) con pulseId también dispara el round-robin', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'create_pulse', boardId: 222, pulseId: 777 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
|
|
})
|
|
|
|
it('create_item en Leads ya NO dispara nada (tablero archivo histórico)', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, itemId: 999 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('create_item en otro board no dispara round-robin', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 555, itemId: 999 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.runRoundRobin).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('change_status_column_value en Oportunidades dispara el escaneo', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: '222', itemId: 1 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('change_column_value en Oportunidades también dispara el escaneo', () => {
|
|
const { res } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'change_column_value', boardId: 222, itemId: 1 } }), res)
|
|
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('update_column_value (nombre real del evento) en Oportunidades dispara el escaneo', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, pulseId: 5 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('cambio de status en otro board no dispara el escaneo de oportunidades', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'change_status_column_value', boardId: 333, itemId: 1 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.escaneoOportunidades).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('update_column_value en Leads dispara el mover a grupo según estado', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 111, itemId: 999 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '111', '999')
|
|
})
|
|
|
|
it('update_column_value en Oportunidades dispara escaneo Y mover a grupo', () => {
|
|
const { res } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 222, itemId: 5 } }), res)
|
|
expect(mocks.escaneoOportunidades).toHaveBeenCalledOnce()
|
|
expect(mocks.moverAGrupo).toHaveBeenCalledWith(expect.anything(), '222', '5')
|
|
})
|
|
|
|
it('update_column_value en un board sin mapeo no dispara el mover a grupo', () => {
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'update_column_value', boardId: 333, itemId: 1 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
expect(mocks.moverAGrupo).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('un error del servicio no rompe la respuesta 200 (fire-and-forget)', async () => {
|
|
mocks.runRoundRobin.mockRejectedValue(new Error('monday caído'))
|
|
const { res, sendStatus } = resFake()
|
|
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 222, itemId: 1 } }), res)
|
|
expect(sendStatus).toHaveBeenCalledWith(200)
|
|
// Deja que el rechazo se procese para no ensuciar otros tests
|
|
await new Promise((r) => setImmediate(r))
|
|
})
|
|
})
|