import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' // ---------- Prisma mockeado (los tests no tocan BD ni red) ---------- const prismaMock = vi.hoisted(() => ({ unidad: { findUnique: vi.fn(), }, capturaDepartamento: { findMany: 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(), }, actividadLocal: { create: vi.fn(), }, automatizacionState: { findUnique: vi.fn(), upsert: vi.fn(), }, auditoriaLog: { create: vi.fn().mockResolvedValue({}), }, // Reglas comerciales configurables por el admin (vacías = constantes) reglaCotizadorDesarrollo: { findMany: vi.fn(), }, $transaction: vi.fn(), })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js' import { DESARROLLOS } from '../services/cotizacion.service.js' /** * 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', desarrollo: 'DIAGONAL', modelo: '2RA', supConstruccion: 60, supExcedente: 0, supEstacionamiento: 0, precioLista: 1_000_000, precioAcabados: 1_100_000, status: 'DISPONIBLE', etapa: '', } const bodyBase = { unidadCodigo: 'U-101', acabados: false, clienteNombre: 'Cliente Demo', clienteTelefono: '4771234567', } /** * Esquema único FRASA 2026 esperado para filaUnidad (precio $1,000,000) con * los defaults del body: apartado $15,000, N=14, escrituración a la firma. * Caso dorado del cliente: hoy $15,000 · firma $65,000 · 14 × $5,357.14. */ const esquemaDorado = { esquema: 'FRASA_2026', apartado: 15000, saldoFirma: 10000, engancheDiferible: 75000, escrituracion: 55000, escrituracionDiferida: false, tipoPropuesta: 'FRASA_2026', descuento: 0, precioLista: 1_000_000, precioFinal: 1_000_000, enganche: 75000, numMensualidades: 14, mensualidad: 75000 / 14, pagoFinal: 65000, } function reqFake(body: unknown): Request { return { body, usuarioToken: { sub: 'u-1' } } as unknown 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() prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad) prismaMock.capturaDepartamento.findMany.mockResolvedValue([]) 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.reglaCotizadorDesarrollo.findMany.mockResolvedValue([]) prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) 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) const respuesta = json.mock.calls[0]?.[0] as Record expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null }) expect(respuesta['esquema']).toBeDefined() expect(respuesta['folio']).toBeDefined() }) it('caso dorado: recalcula el esquema FRASA 2026 server-side y persiste columnas nuevas y legacy', async () => { const { res, json } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining(esquemaDorado) }) const respuesta = json.mock.calls[0]?.[0] as { esquema: { saldoFirma: number; pagoUnicoFirma: number; mensualidad: number; baseDiferible: number } } expect(respuesta.esquema.saldoFirma).toBe(10000) expect(respuesta.esquema.pagoUnicoFirma).toBe(65000) expect(respuesta.esquema.baseDiferible).toBe(75000) expect(respuesta.esquema.mensualidad).toBeCloseTo(75000 / 14, 2) }) it('escrituración diferida: entra al diferible y baja el pago a la firma', async () => { const { res, json } = resFake() await crearCotizacion(reqFake({ ...bodyBase, escrituracionDiferida: true }), res) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ escrituracionDiferida: true, pagoFinal: 10000, mensualidad: 130000 / 14, }), }) const respuesta = json.mock.calls[0]?.[0] as { esquema: { baseDiferible: number } } expect(respuesta.esquema.baseDiferible).toBe(130000) }) it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', async () => { const { res, json } = resFake() await crearCotizacion(reqFake({ ...bodyBase, apartado: 30000 }), res) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ apartado: 30000, saldoFirma: 0, enganche: 70000, pagoFinal: 55000 }), }) const respuesta = json.mock.calls[0]?.[0] as { esquema: { excedenteApartado: number; totalPagado: number } } expect(respuesta.esquema.excedenteApartado).toBe(5000) expect(respuesta.esquema.totalPagado).toBeCloseTo(155000, 2) }) it('la unidad no existe en el espejo → 404 sin crear cotización', async () => { prismaMock.unidad.findUnique.mockResolvedValue(null) const { res } = resFake() await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 404 }) expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) 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 }) }) 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), res) expect(status).toHaveBeenCalledWith(201) 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' }, }) }) 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() }) it('sin numMensualidades usa el default del esquema (14)', async () => { prismaMock.capturaDepartamento.findMany.mockResolvedValue([ { unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, ]) const { res, json } = resFake() await crearCotizacion(reqFake(bodyBase), res) const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number mensualidad: number esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number } } expect(respuesta.numMensualidades).toBe(14) expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades) expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / 14, 6) }) it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => { const { res, json } = resFake() await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res) // Enganche diferible $75,000 en 12 pagos expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }), }) const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } } expect(respuesta.numMensualidades).toBe(12) expect(respuesta.esquema.numMensualidades).toBe(12) }) it('rechaza mensualidades fuera de rango (1..36) sin persistir', async () => { const { res } = resFake() await expect(crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 48 }), res)).rejects.toBeDefined() expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) it('persiste y devuelve las opciones de crédito que manda el cotizador', async () => { const opcionesCredito = JSON.stringify({ elegida: 'CONTADO', ajustes: { CREDITO_BANCARIO: { tasaAnual: 0.105, plazoAnos: 20 }, CREDITO_INFONAVIT_COFINAVIT: { tasaAnual: 0.105, plazoAnos: 25 }, SEMI_CONTADO: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 }, CONTADO: { tasaAnual: 0, plazoAnos: 1 }, }, }) const { res, status, json } = resFake() await crearCotizacion(reqFake({ ...bodyBase, opcionesCredito }), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ opcionesCredito }), }) // La respuesta del POST incluye el campo (los GET devuelven la fila completa) expect(json.mock.calls[0]?.[0]).toMatchObject({ opcionesCredito }) }) it('sin opciones de crédito persiste null', async () => { const { res } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ opcionesCredito: null }), }) }) it('rechaza opciones de crédito que exceden 4000 caracteres', async () => { const { res } = resFake() await expect( crearCotizacion(reqFake({ ...bodyBase, opcionesCredito: 'x'.repeat(4001) }), res), ).rejects.toBeDefined() expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => { prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' }) const { res } = resFake() await expect(crearCotizacion(reqFake({ ...bodyBase, unidadCodigo: 'ATELIER-10' }), res)).rejects.toMatchObject({ status: 409, }) expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) }) describe('crearCotizacion — reglas comerciales del admin', () => { /** Reglas guardadas del desarrollo, con los ajustes del test */ function reglasGuardadas( desarrollo: string, ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {}, ) { const base = DESARROLLOS[desarrollo]! return [ { desarrolloId: desarrollo, parametrosJson: JSON.stringify({ ...base, ...(ajustes.activoEnCotizador !== undefined ? { activoEnCotizador: ajustes.activoEnCotizador } : {}), propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== ajustes.propuestaInactiva, ...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined ? { descuentoDefault: ajustes.descuentoContado } : {}), })), }), }, ] } it('sin reglas guardadas cotiza con el esquema único (respaldo en constantes)', async () => { const { res, status } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }), }) }) it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => { prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }), ) const { res, status } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }), }) }) it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => { prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }), ) const { res, status } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalled() }) it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => { prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( reglasGuardadas('DIAGONAL', { activoEnCotizador: false }), ) const { res } = resFake() await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 409, message: expect.stringContaining('no está disponible en el cotizador'), }) expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([ { desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' }, ]) const { res, status } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }), }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido')) warn.mockRestore() }) }) 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() }) })