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] 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] 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') }) })