Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
176 lines
7.5 KiB
TypeScript
176 lines
7.5 KiB
TypeScript
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) ----------
|
|
|
|
const prismaMock = vi.hoisted(() => ({
|
|
cotizacion: {
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
syncPendiente: {
|
|
create: 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(),
|
|
}))
|
|
|
|
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'
|
|
|
|
const unidadFake = {
|
|
codigo: 'U-101',
|
|
modelo: 'M1',
|
|
fechaEntrega: null,
|
|
supEstacionamiento: 0,
|
|
supConstruccion: 60,
|
|
supExcedente: 0,
|
|
precioSinAcabados: 1_000_000,
|
|
precioConAcabados: 1_100_000,
|
|
status: 'DISPONIBLE',
|
|
desarrollo: 'DIAGONAL',
|
|
}
|
|
|
|
const bodyBase = {
|
|
unidadCodigo: 'U-101',
|
|
acabados: false,
|
|
clienteNombre: 'Cliente Demo',
|
|
clienteTelefono: '4771234567',
|
|
tipoPropuesta: 'BANCARIO' as const,
|
|
}
|
|
|
|
function reqFake(body: unknown): Request {
|
|
return { body } as Request
|
|
}
|
|
|
|
function resFake() {
|
|
const json = vi.fn()
|
|
const status = vi.fn().mockReturnValue({ json })
|
|
return { res: { json, status } as unknown as Response, status, json }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mondayMock.getUnidad.mockResolvedValue(unidadFake)
|
|
mondayMock.crearOportunidad.mockResolvedValue('op-nueva')
|
|
mondayMock.actualizarOportunidadCotizacion.mockResolvedValue(undefined)
|
|
funnelMock.oportunidadPorTelefono.mockResolvedValue(null)
|
|
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
|
|
prismaMock.cotizacion.update.mockResolvedValue({})
|
|
})
|
|
|
|
describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', () => {
|
|
it('sin oportunidad mapeada para el teléfono: crea una nueva (comportamiento actual)', async () => {
|
|
const { res, status, json } = resFake()
|
|
await crearCotizacion(reqFake(bodyBase), res)
|
|
expect(status).toHaveBeenCalledWith(201)
|
|
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
|
|
expect(mondayMock.actualizarOportunidadCotizacion).not.toHaveBeenCalled()
|
|
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'op-nueva' })
|
|
})
|
|
|
|
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 () => {
|
|
const { res } = resFake()
|
|
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
|
|
await crearCotizacion(reqFake(sinTelefono), res)
|
|
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
|
|
expect(mondayMock.crearOportunidad).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
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({})
|
|
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 () => {
|
|
const { res, status, json } = resFake()
|
|
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), 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: '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')
|
|
})
|
|
})
|