import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { actividadEconomicaLabel, checkboxParaTipoDocumento, perfilDemografico, rangoEdadLabel, } from './clientes.controller.js' // ---------- Mocks de módulo (los tests no tocan BD ni red) ---------- const prismaMock = vi.hoisted(() => ({ cliente: { create: vi.fn(), update: vi.fn(), }, usuario: { findUnique: vi.fn(), }, syncPendiente: { create: vi.fn(), }, })) const mondayMock = vi.hoisted(() => ({ crearContacto: 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 })) describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => { it('mapea los tipos con checkbox al título de la columna', () => { expect(checkboxParaTipoDocumento('CURP')).toBe('CURP') expect(checkboxParaTipoDocumento('RFC')).toBe('RFC') expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC') expect(checkboxParaTipoDocumento('COMPROBANTE')).toBe('Comprobante') expect(checkboxParaTipoDocumento('COMPROBANTE_DOMICILIO')).toBe('Comprobante') }) it('normaliza mayúsculas y espacios', () => { expect(checkboxParaTipoDocumento(' curp ')).toBe('CURP') expect(checkboxParaTipoDocumento('comprobante_domicilio')).toBe('Comprobante') }) it('INE y otros tipos no tienen checkbox (null)', () => { expect(checkboxParaTipoDocumento('INE')).toBeNull() expect(checkboxParaTipoDocumento('ACTA_NACIMIENTO')).toBeNull() expect(checkboxParaTipoDocumento('OTRO')).toBeNull() }) }) describe('rangoEdadLabel', () => { const hoy = new Date('2026-08-22T12:00:00') it('deriva el rango del CRM a partir de la fecha de nacimiento', () => { expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40') expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40') }) it('devuelve undefined con fechas inválidas o ausentes', () => { expect(rangoEdadLabel(undefined, hoy)).toBeUndefined() expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined() expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined() }) }) describe('perfilDemografico (alta → Contacto Monday)', () => { it('mapea los valores del formato a los labels del CRM', () => { const perfil = perfilDemografico({ estadoCivil: 'UNION_LIBRE', tipoVivienda: 'FAMILIAR', tipoCompra: 'VIVIR_AHI', fechaNacimiento: '1990-05-10', }) expect(perfil.estadoCivilLabel).toBe('Union Libre') expect(perfil.viviendaActualLabel).toBe('Comparte con familia') expect(perfil.propositoCompraLabel).toBe('Habitarla') expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40') expect(perfil.fechaNacimiento).toBe('1990-05-10') }) it('omite campos vacíos o desconocidos (no inventa datos)', () => { expect(perfilDemografico({})).toEqual({}) expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({}) }) }) describe('actividadEconomicaLabel', () => { it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => { expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado') expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina expect(actividadEconomicaLabel(undefined)).toBeUndefined() }) }) describe('crearClienteSchema', () => { const base = { datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, viviendaCredito: { proyecto: 'SENDA' }, } it('acepta un alta sin ubicación (opcional en el formato web)', async () => { const { crearClienteSchema } = await import('./clientes.controller.js') expect(() => crearClienteSchema.parse(base)).not.toThrow() }) it('sigue exigiendo nombre, curp, rfc y proyecto', async () => { const { crearClienteSchema } = await import('./clientes.controller.js') expect(() => crearClienteSchema.parse({ datosGenerales: { nombreCompleto: '', curp: '', rfc: '' }, viviendaCredito: {} }), ).toThrow() }) }) describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => { // Import diferido para que los vi.mock de arriba apliquen al controller const altaBase = { datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, viviendaCredito: { proyecto: 'SENDA' }, } const reqFake = (body: unknown): Request => ({ body } as Request) const 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.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.update.mockResolvedValue({}) prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado mondayMock.crearContacto.mockResolvedValue('cont-1') funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') }) it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => { const { crearCliente } = await import('./clientes.controller.js') const { prismaStateStore } = await import('../services/automatizaciones.service.js') const { res, status } = resFake() await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res) expect(status).toHaveBeenCalledWith(201) expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore) }) it('sin leadMondayId no toca el funnel', async () => { const { crearCliente } = await import('./clientes.controller.js') const { res, status } = resFake() await crearCliente(reqFake(altaBase), res) expect(status).toHaveBeenCalledWith(201) expect(funnelMock.moverAApartoConDedup).not.toHaveBeenCalled() }) it('un fallo del funnel no rompe el alta (201 igual)', async () => { const { crearCliente } = await import('./clientes.controller.js') funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído')) const { res, status, json } = resFake() await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res) expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' }) }) it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => { const { crearCliente } = await import('./clientes.controller.js') await crearCliente({ body: altaBase, usuarioToken: { sub: 'usr-1' } } as unknown as Request, resFake().res) expect(prismaMock.cliente.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }), ) await crearCliente(reqFake(altaBase), resFake().res) expect(prismaMock.cliente.create).toHaveBeenLastCalledWith( expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }), ) }) }) describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => { it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => { const { formaPagoLabel } = await import('./clientes.controller.js') expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit') expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit') expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit') expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito') expect(formaPagoLabel(['CONTADO'])).toBe('Contado') expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste') expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste') expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario') }) it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => { const { formaPagoLabel } = await import('./clientes.controller.js') expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado') expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined() expect(formaPagoLabel([])).toBeUndefined() expect(formaPagoLabel(undefined)).toBeUndefined() expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo }) }) describe('crearCliente — Apartado con ficha completa', () => { const altaConVenta = { datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', email: 'cli@correo.com', celular: '6641112233', }, viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000, tiposCredito: ['INFONAVIT'], }, } const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request => ({ body, usuarioToken } 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 } } beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.update.mockResolvedValue({}) prismaMock.usuario.findUnique.mockResolvedValue(null) mondayMock.crearContacto.mockResolvedValue('cont-1') mondayMock.crearApartado.mockResolvedValue('ap-1') funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') }) it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => { const { crearCliente } = await import('./clientes.controller.js') const { res, status, json } = resFake() await crearCliente(reqFake(altaConVenta), res) expect(status).toHaveBeenCalledWith(201) expect(mondayMock.crearApartado).toHaveBeenCalledWith( expect.objectContaining({ clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', montoApartado: 20000, estadoLabel: 'Pago Apartado', telefono: '6641112233', email: 'cli@correo.com', curp: 'CURP123', precioFinal: 1500000, enganche: 20000, formaPagoLabel: 'Infonavit', fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), }), ) expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' }) }) it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => { const { crearCliente } = await import('./clientes.controller.js') prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 }) await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res) expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 })) }) it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => { const { crearCliente } = await import('./clientes.controller.js') await crearCliente(reqFake(altaConVenta), resFake().res) expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined })) }) }) describe('réplica diferida (encolado cuando Monday falla)', () => { const altaConVenta = { datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' }, viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 }, } const reqFake = (body: unknown): Request => ({ body } as Request) const resFake = () => { const json = vi.fn() const status = vi.fn().mockReturnValue({ json }) return { res: { json, status } as unknown as Response, status, json } } function tiposEncolados(): string[] { return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) } beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.update.mockResolvedValue({}) prismaMock.usuario.findUnique.mockResolvedValue(null) prismaMock.syncPendiente.create.mockResolvedValue({}) funnelMock.moverAApartoConDedup.mockResolvedValue('op-1') vi.spyOn(console, 'error').mockImplementation(() => {}) }) it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => { const { crearCliente } = await import('./clientes.controller.js') mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído')) const { res, status, json } = resFake() await crearCliente(reqFake(altaConVenta), res) expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null }) expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO']) // El payload del Contacto lleva los datos del alta (el worker no necesita nada más) const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as { data: { payloadJson: string; referenciaLocal: string } } expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({ nombre: 'Cliente Demo', estadoLabel: 'CLIENTE', curp: 'CURP123', }) expect(llamadaContacto.data.referenciaLocal).toBe('cli-1') // El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as { data: { payloadJson: string; referenciaLocal: string } } expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({ clienteId: 'cli-1', datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' }, }) }) it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => { const { crearCliente } = await import('./clientes.controller.js') mondayMock.crearContacto.mockResolvedValue('cont-1') mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído')) const { res, status, json } = resFake() await crearCliente(reqFake(altaConVenta), res) expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null }) expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: { mondayContactoId: 'cont-1', mondayApartadoId: null }, }) }) it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => { const { crearCliente } = await import('./clientes.controller.js') mondayMock.crearContacto.mockResolvedValue('cont-1') mondayMock.crearApartado.mockResolvedValue('ap-1') funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído')) const { res, status } = resFake() await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res) expect(status).toHaveBeenCalledWith(201) expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD']) expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({ data: expect.objectContaining({ tipo: 'APARTO_OPORTUNIDAD', payloadJson: JSON.stringify({ leadId: 'lead-9' }), referenciaLocal: 'lead-9', }), }) }) })