feat(api): webhooks Monday en tiempo real + alertas de inventario cableadas
- Webhook Oportunidades: cambio de etapa dispara escaneo (Aparto→Apartado, Ganado→Venta) además del round-robin de leads por create_item - Acepta los nombres reales de los eventos de Monday: create_pulse/pulseId y update_column_value (verificados en vivo) - Token interno (X-Internal-Token) para que los syncs locales llamen POST /cotizaciones/alertas-inventario sin JWT de usuario - sync-estatus-maestro.py notifica los cambios aplicados al endpoint de alertas (actividad al vendedor con cotizaciones recientes) - Fix P1008 SQLite: escaneos del poll cron secuenciales + connection_limit=1 en DATABASE_URL - Webhooks registrados en Monday sobre https://frasa.consultoria-as.com (Leads create_item, Oportunidades change_column_value); crons quedan como red de seguridad - Docs: sección 0 en AUTOMATIZACIONES-MONDAY.md con la matriz de eventos y los quirks de Monday
This commit is contained in:
112
api-gateway/src/controllers/webhooks.controller.test.ts
Normal file
112
api-gateway/src/controllers/webhooks.controller.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runRoundRobin: vi.fn(),
|
||||
escaneoOportunidades: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/automatizaciones.service.js', () => ({
|
||||
automatizacionesService: {
|
||||
runRoundRobin: mocks.runRoundRobin,
|
||||
escaneoOportunidades: mocks.escaneoOportunidades,
|
||||
},
|
||||
}))
|
||||
|
||||
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: [] })
|
||||
})
|
||||
|
||||
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 Leads dispara el round-robin del item', () => {
|
||||
const { res, sendStatus } = resFake()
|
||||
webhookMonday(reqCon({ event: { type: 'create_item', boardId: 111, 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: 111, pulseId: 777 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
expect(mocks.runRoundRobin).toHaveBeenCalledWith('777')
|
||||
})
|
||||
|
||||
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('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: 111, itemId: 1 } }), res)
|
||||
expect(sendStatus).toHaveBeenCalledWith(200)
|
||||
// Deja que el rechazo se procese para no ensuciar otros tests
|
||||
await new Promise((r) => setImmediate(r))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user