import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { contactoDesdeDatos, textoDireccion } from './clientes.controller.js' // ---------- Mocks de módulo (los tests no tocan BD ni red) ---------- const prismaMock = vi.hoisted(() => ({ cliente: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), }, usuario: { findUnique: vi.fn(), }, })) const mondayMock = vi.hoisted(() => ({ crearContacto: vi.fn(), actualizarContacto: vi.fn(), crearApartado: vi.fn(), })) const funnelMock = vi.hoisted(() => ({ moverAApartoConDedup: 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 })) const reqFake = (body: unknown, params: Record = {}): Request => ({ body, params }) as unknown as Request const resFake = () => { const json = vi.fn() const status = vi.fn().mockReturnValue({ json }) return { res: { json, status } as unknown as Response, status, json } } const datosWizard = { datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', email: 'ana@correo.com', celular: '6649990003', estadoCivil: 'SOLTERO', tipoVivienda: 'RENTADA', tipoCompra: 'VIVIR_AHI', fechaNacimiento: '1990-01-01', }, empleo: { tipoEmpleo: 'ASALARIADO' }, viviendaCredito: { proyecto: 'DIAGONAL', domicilioFiscal: { calle: 'Av. Siempre Viva', numExt: '123', numInt: '4', colonia: 'Col. Centro', cp: '22000', municipio: 'Tijuana', estado: 'Baja California', }, }, } beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado mondayMock.crearContacto.mockResolvedValue('cont-1') mondayMock.actualizarContacto.mockResolvedValue(undefined) mondayMock.crearApartado.mockResolvedValue('ap-1') funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') }) describe('textoDireccion (domicilio del wizard → columna Dirección)', () => { it('arma calle con ext/int, colonia, CP, municipio y estado', () => { expect( textoDireccion({ calle: 'Av. Siempre Viva', numExt: '123', numInt: '4', colonia: 'Col. Centro', cp: '22000', municipio: 'Tijuana', estado: 'Baja California', }), ).toBe('Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California') }) it('omite las partes vacías y devuelve undefined si no hay nada', () => { expect(textoDireccion({ calle: 'Calle Uno', municipio: 'Tijuana' })).toBe('Calle Uno, Tijuana') expect(textoDireccion({})).toBeUndefined() expect(textoDireccion({ calle: ' ' })).toBeUndefined() }) }) describe('contactoDesdeDatos (estado del wizard → campos del Contacto)', () => { it('deriva todos los campos mapeables presentes', () => { const c = contactoDesdeDatos(datosWizard) expect(c).toMatchObject({ nombre: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', email: 'ana@correo.com', telefono: '6649990003', direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California', estadoCivilLabel: 'Soltero', viviendaActualLabel: 'Rentada', propositoCompraLabel: 'Habitarla', rangoEdadLabel: 'Entre 24 y 40', fechaNacimiento: '1990-01-01', actividadEconomicaLabel: 'Empleado', }) }) it('solo incluye campos presentes (no inventa datos)', () => { const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }) expect(c).toEqual({ nombre: 'Solo Nombre' }) }) it('sin domicilioFiscal usa el texto libre datosGenerales.direccion', () => { const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Ana', direccion: 'Calle Libre 1' }, }) expect(c.direccion).toBe('Calle Libre 1') }) }) describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => { it('crea el Cliente local y el Contacto en Monday con Estado PROSPECTO', async () => { const { iniciarProgreso } = await import('./clientes.controller.js') const { res, status, json } = resFake() await iniciarProgreso( reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', celular: '6649990003', }, }), res, ) expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: 'cont-1' }) expect(mondayMock.crearContacto).toHaveBeenCalledWith({ nombre: 'Ana Demo Progreso', estadoLabel: 'PROSPECTO', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', telefono: '6649990003', }) expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: { mondayContactoId: 'cont-1' }, }) }) it('requiere solo datosGenerales.nombreCompleto', async () => { const { iniciarProgreso } = await import('./clientes.controller.js') const { res, status } = resFake() await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res) expect(status).toHaveBeenCalledWith(201) expect(mondayMock.crearContacto).toHaveBeenCalledWith({ nombre: 'Solo Nombre', estadoLabel: 'PROSPECTO', }) await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow() }) it('fallo de Monday: crea el local igual y devuelve mondayContactoId null', async () => { const { iniciarProgreso } = await import('./clientes.controller.js') mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído')) const { res, status, json } = resFake() await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), res) expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null }) }) it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => { const { iniciarProgreso } = await import('./clientes.controller.js') const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }) ;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' } await iniciarProgreso(req, resFake().res) expect(prismaMock.cliente.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }), ) await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res) expect(prismaMock.cliente.create).toHaveBeenLastCalledWith( expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }), ) }) }) describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => { it('deriva los campos presentes y actualiza el Contacto (incl. Dirección armada)', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' }) const { res, json } = resFake() await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res) expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: { payloadJson: JSON.stringify(datosWizard) }, }) expect(mondayMock.actualizarContacto).toHaveBeenCalledWith( 'cont-1', expect.objectContaining({ nombre: 'Ana Demo Progreso', direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California', actividadEconomicaLabel: 'Empleado', telefono: '6649990003', }), ) expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: true }) }) it('sin mondayContactoId responde mondaySync: false y no toca Monday', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null }) const { res, json } = resFake() await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res) expect(mondayMock.actualizarContacto).not.toHaveBeenCalled() expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false }) }) it('fallo de Monday: 200 igual con mondaySync: false', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' }) mondayMock.actualizarContacto.mockRejectedValue(new Error('Monday caído')) const { res, json } = resFake() await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res) expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false }) }) it('rechaza un body que no sea objeto', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') await expect(actualizarProgreso(reqFake('no-objeto', { id: 'cli-1' }), resFake().res)).rejects.toThrow() }) }) describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () => { const altaCompleta = { ...datosWizard, viviendaCredito: { ...datosWizard.viviendaCredito, ubicacion: 'U-999-DEMO2', valorVenta: 1500000, enganche: 20000, }, firma: { firmaDataUrl: 'data:image/png;base64,AAAA' }, leadMondayId: 'lead-9', } it('pasa el Contacto a CLIENTE, crea el Apartado y mueve el lead a Aparto (dedup)', async () => { const { completarProgreso } = await import('./clientes.controller.js') const { prismaStateStore } = await import('../services/automatizaciones.service.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1', mondayApartadoId: null, }) const { res, status, json } = resFake() await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res) expect(status).toHaveBeenCalledWith(201) expect(mondayMock.actualizarContacto).toHaveBeenCalledWith( 'cont-1', expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }), ) expect(mondayMock.crearApartado).toHaveBeenCalledWith( expect.objectContaining({ clienteNombre: 'Ana Demo Progreso', unidadCodigo: 'U-999-DEMO2', montoApartado: 20000, estadoLabel: 'Pago Apartado', }), ) expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1', mondayApartadoId: 'ap-1', }) }) it('el Apartado lleva la ficha completa: contacto, precio, Forma de Pago y Vendedor del capturista original', async () => { const { completarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1', mondayApartadoId: null, capturadoPorId: 'usr-9', // quien inició el alta progresiva }) prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) const altaConCredito = { ...altaCompleta, viviendaCredito: { ...altaCompleta.viviendaCredito, tiposCredito: ['COFI_FOVISSSTE'] }, } const { res, status } = resFake() await completarProgreso(reqFake(altaConCredito, { id: 'cli-1' }), res) expect(status).toHaveBeenCalledWith(201) expect(mondayMock.crearApartado).toHaveBeenCalledWith( expect.objectContaining({ clienteNombre: 'Ana Demo Progreso', unidadCodigo: 'U-999-DEMO2', telefono: '6649990003', email: 'ana@correo.com', curp: 'AEDA900101MBCXXX00', precioFinal: 1500000, enganche: 20000, vendedorId: 12345, formaPagoLabel: 'Cofi-Fovissste', fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), }), ) }) it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => { const { completarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null, }) const { res, status } = resFake() await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res) expect(status).toHaveBeenCalledWith(201) expect(mondayMock.actualizarContacto).not.toHaveBeenCalled() expect(mondayMock.crearContacto).toHaveBeenCalledWith( expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }), ) }) it('rechaza un payload incompleto (misma validación que POST /clientes)', async () => { const { completarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' }) await expect( completarProgreso( reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }), resFake().res, ), ).rejects.toThrow() expect(prismaMock.cliente.update).not.toHaveBeenCalled() }) it('404 si el progreso no existe', async () => { const { completarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue(null) await expect( completarProgreso(reqFake(altaCompleta, { id: 'no-existe' }), resFake().res), ).rejects.toThrow('Cliente no encontrado') }) })