feat(purga): Monday eliminado completamente del sistema
14 servicios eliminados, 7 controllers local-only, crons locales, config sin MONDAY_*, frontend sin banners/avisos Monday. 552 tests API + 7 apps construidas.
This commit is contained in:
@@ -1,48 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// ---------- Prisma, Monday y Funnel mockeados (los tests no tocan BD ni red) ----------
|
||||
// ---------- Prisma mockeado (los tests no tocan BD ni red) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
unidad: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
leadCaptura: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
leadOportunidad: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
cotizacion: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
actividadLocal: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
automatizacionState: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
getUnidad: vi.fn(),
|
||||
crearOportunidad: vi.fn(),
|
||||
actualizarOportunidadCotizacion: vi.fn(),
|
||||
}))
|
||||
|
||||
const funnelMock = vi.hoisted(() => ({
|
||||
oportunidadPorTelefono: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
|
||||
|
||||
import { crearCotizacion } from './cotizaciones.controller.js'
|
||||
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
|
||||
|
||||
const unidadFake = {
|
||||
/**
|
||||
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
|
||||
* cotización se persiste en SQLite y se liga a la oportunidad local (que pasa
|
||||
* a etapa 'Cotizador').
|
||||
*/
|
||||
|
||||
const filaUnidad = {
|
||||
codigo: 'U-101',
|
||||
modelo: 'M1',
|
||||
fechaEntrega: null,
|
||||
supEstacionamiento: 0,
|
||||
desarrollo: 'DIAGONAL',
|
||||
modelo: '2RA',
|
||||
supConstruccion: 60,
|
||||
supExcedente: 0,
|
||||
precioSinAcabados: 1_000_000,
|
||||
precioConAcabados: 1_100_000,
|
||||
supEstacionamiento: 0,
|
||||
precioLista: 1_000_000,
|
||||
precioAcabados: 1_100_000,
|
||||
status: 'DISPONIBLE',
|
||||
desarrollo: 'DIAGONAL',
|
||||
etapa: '',
|
||||
}
|
||||
|
||||
const bodyBase = {
|
||||
@@ -54,7 +65,7 @@ const bodyBase = {
|
||||
}
|
||||
|
||||
function reqFake(body: unknown): Request {
|
||||
return { body } as Request
|
||||
return { body, usuarioToken: { sub: 'u-1' } } as unknown as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
@@ -65,111 +76,164 @@ function resFake() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mondayMock.getUnidad.mockResolvedValue(unidadFake)
|
||||
mondayMock.crearOportunidad.mockResolvedValue('op-nueva')
|
||||
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
|
||||
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
|
||||
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
|
||||
prismaMock.cotizacion.update.mockResolvedValue({})
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
||||
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
|
||||
describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () => {
|
||||
it('sin oportunidad mapeada para el teléfono: crea una nueva (comportamiento actual)', async () => {
|
||||
describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
it('cotiza con la unidad del espejo local (tabla Unidad)', async () => {
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
|
||||
expect(mondayMock.actualizarOportunidadCotizacion).not.toHaveBeenCalled()
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-nueva' })
|
||||
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
|
||||
expect(respuesta['propuesta']).toBeDefined()
|
||||
expect(respuesta['folio']).toBeDefined()
|
||||
})
|
||||
|
||||
it('con teléfono de lead mapeado: ACTUALIZA esa oportunidad (no duplica) y la pasa a Cotizador', async () => {
|
||||
funnelMock.oportunidadPorTelefono.mockResolvedValue('op-del-lead')
|
||||
const { res, status, json } = resFake()
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(funnelMock.oportunidadPorTelefono).toHaveBeenCalledWith('4771234567')
|
||||
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
|
||||
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
|
||||
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
|
||||
expect(opId).toBe('op-del-lead')
|
||||
expect(datos).toMatchObject({ unidadCodigo: 'U-101', tipoPropuestaLabel: 'CREDITO BANCARIO', estadoLabel: 'Cotizador' })
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-del-lead' })
|
||||
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cot-1' },
|
||||
data: { mondayItemId: 'op-del-lead' },
|
||||
})
|
||||
})
|
||||
|
||||
it('sin teléfono en el body no consulta el funnel', async () => {
|
||||
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(null)
|
||||
const { res } = resFake()
|
||||
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
|
||||
await crearCotizacion(reqFake(sinTelefono), res)
|
||||
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
|
||||
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
|
||||
|
||||
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 404 })
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('si Monday falla, la cotización igual se crea (201 con mondayItemId null) y la vinculación queda encolada', async () => {
|
||||
mondayMock.crearOportunidad.mockRejectedValue(new Error('Monday caído'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
prismaMock.syncPendiente.create.mockResolvedValue({})
|
||||
it('sin oportunidad mapeada para el teléfono: mondayItemId null (no crea oportunidades)', async () => {
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
||||
// COTIZACION_OPORTUNIDAD con los datos completos + teléfono para el match del worker
|
||||
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tipo: 'COTIZACION_OPORTUNIDAD',
|
||||
referenciaLocal: 'cot-1',
|
||||
}),
|
||||
})
|
||||
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
|
||||
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({
|
||||
clienteTelefono: '4771234567',
|
||||
datos: { unidadCodigo: 'U-101', estadoLabel: 'Cotizador' },
|
||||
})
|
||||
})
|
||||
|
||||
it('si falla la actualización con oportunidadId explícito, se encola con ESE id', async () => {
|
||||
mondayMock.actualizarOportunidadCotizacion.mockRejectedValue(new Error('Monday caído'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
prismaMock.syncPendiente.create.mockResolvedValue({})
|
||||
const { res, status } = resFake()
|
||||
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const llamada = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string } }
|
||||
expect(JSON.parse(llamada.data.payloadJson)).toMatchObject({ oportunidadId: 'opp-midia-1' })
|
||||
})
|
||||
|
||||
it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {
|
||||
it('con teléfono de una afluencia local: LIGA esa oportunidad y la pasa a Cotizador', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue({
|
||||
leadId: 'loc_op_1',
|
||||
board: 'OPORTUNIDADES',
|
||||
telefono: '4771234567',
|
||||
})
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_1', board: 'OPORTUNIDADES' })
|
||||
const { res, status, json } = resFake()
|
||||
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
|
||||
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
|
||||
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
|
||||
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
|
||||
expect(opId).toBe('opp-midia-1')
|
||||
expect(datos).toMatchObject({ estadoLabel: 'Cotizador' })
|
||||
// La cotización queda vinculada al item del cliente
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_1' })
|
||||
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cot-1' },
|
||||
data: { mondayItemId: 'loc_op_1' },
|
||||
})
|
||||
// La oportunidad pasa a etapa 'Cotizador' localmente
|
||||
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
|
||||
where: { leadId: 'loc_op_1' },
|
||||
data: { etapa: 'Cotizador', pendienteSync: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('con teléfono de un lead histórico: usa la oportunidad del mapping lead→oportunidad', async () => {
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue({
|
||||
leadId: 'loc_lead_1',
|
||||
board: 'LEADS',
|
||||
telefono: '4771234567',
|
||||
})
|
||||
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_mapping' })
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_mapping', board: 'OPORTUNIDADES' })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_mapping' })
|
||||
})
|
||||
|
||||
it('con oportunidadId explícito (botón "Cotizar" de Mi día): liga ESE id sin buscar por teléfono', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'opp-midia-1', board: 'OPORTUNIDADES' })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
|
||||
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cot-1' },
|
||||
data: { mondayItemId: 'opp-midia-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
|
||||
it('los 5 códigos del enum mapean a los labels sembrados en la columna "Tipo propuesta"', async () => {
|
||||
const { LABEL_PROPUESTA_MONDAY } = await import('./cotizaciones.controller.js')
|
||||
// Labels reales verificados en Monday (difieren en guiones/espacios)
|
||||
expect(LABEL_PROPUESTA_MONDAY['BANCARIO']).toBe('CREDITO BANCARIO')
|
||||
expect(LABEL_PROPUESTA_MONDAY['INFONAVIT']).toBe('INFONAVIT-COFINAVIT')
|
||||
expect(LABEL_PROPUESTA_MONDAY['COFINAVIT']).toBe('INFONAVIT-COFINAVIT')
|
||||
expect(LABEL_PROPUESTA_MONDAY['CONTADO']).toBe('CONTADO')
|
||||
expect(LABEL_PROPUESTA_MONDAY['SEMI_CONTADO']).toBe('SEMI CONTADO')
|
||||
it('sin teléfono en el body no busca oportunidades', async () => {
|
||||
const { res } = resFake()
|
||||
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
|
||||
|
||||
await crearCotizacion(reqFake(sinTelefono), res)
|
||||
|
||||
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
|
||||
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await alertasInventarioEndpoint(
|
||||
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
|
||||
res,
|
||||
)
|
||||
|
||||
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 })
|
||||
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
titulo: 'La unidad U-101 que cotizaste pasó a APARTADA',
|
||||
usuarioId: 'usr-1',
|
||||
}),
|
||||
})
|
||||
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { clave: 'alerta-inv:U-101:cot-9:APARTADA' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sin vendedor en la cotización: omitida (sin actividad)', async () => {
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: null }])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await alertasInventarioEndpoint(
|
||||
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }] } } as unknown as Request,
|
||||
res,
|
||||
)
|
||||
|
||||
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
|
||||
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ya alertada (dedup persistente): omitida', async () => {
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'x', valor: 'y' })
|
||||
const { res, json } = resFake()
|
||||
|
||||
await alertasInventarioEndpoint(
|
||||
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
|
||||
res,
|
||||
)
|
||||
|
||||
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
|
||||
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user