import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Request, Response } from 'express' import { checkboxParaTipoDocumento, formaPagoLabel } 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(), }, leadCaptura: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), }, leadOportunidad: { findUnique: vi.fn(), }, usuario: { findUnique: vi.fn(), }, automatizacionState: { findUnique: vi.fn(), upsert: vi.fn(), }, recordatorioVendedor: { updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, auditoriaLog: { create: vi.fn().mockResolvedValue({}), }, $transaction: vi.fn(), })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) describe('checkboxParaTipoDocumento (tipos de documento del expediente)', () => { it('mapea los tipos con checkbox al nombre del checkbox', () => { 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('formaPagoLabel (tipos de crédito del alta)', () => { it('mapea los 8 valores del enum del formato', 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('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() }) }) // ---------- Alta local (POST /api/clientes) ---------- const altaBase = { datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, viviendaCredito: { proyecto: 'SENDA' }, } const reqFake = (body: unknown, params: Record = {}, usuarioToken?: { sub: string }): Request => ({ body, params, 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 } } const oportunidadLocal = { leadId: 'loc_op_9', board: 'OPORTUNIDADES', nombreLead: 'Lead Prueba', usuarioId: 'u-1', telefono: '4771234567', email: null, desarrollo: 'SENDA', modelo: 'SOHO', datosJson: null, } describe('crearCliente (POST /api/clientes) — alta local', () => { beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal) prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) it('201: persiste el Cliente en SQLite y responde mondayContactoId null', async () => { const { crearCliente } = await import('./clientes.controller.js') const { res, status, json } = resFake() await crearCliente(reqFake(altaBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cliente.create).toHaveBeenCalledWith({ data: expect.objectContaining({ nombreCompleto: 'Cliente Demo', proyecto: 'SENDA' }), }) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null }) }) it('con leadMondayId mueve la oportunidad a Aparto y crea el apartado local (dedup)', async () => { const { crearCliente } = await import('./clientes.controller.js') // El id del picker es una oportunidad directa (modelo nuevo) prismaMock.leadOportunidad.findUnique .mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // buscarPorOportunidadId .mockResolvedValue(null) const { res, status, json } = resFake() await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res) expect(status).toHaveBeenCalledWith(201) // Apartado local creado (board APARTADO, primer paso del pipeline) y encolado expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }), }) // La ficha del cliente queda ligada al apartado local expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: { mondayApartadoId: expect.stringMatching(/^loc_/) }, }) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: expect.stringMatching(/^loc_/), }) }) it('con leadMondayId de un lead histórico resuelve la oportunidad vía mapping', async () => { const { crearCliente } = await import('./clientes.controller.js') prismaMock.leadOportunidad.findUnique .mockResolvedValueOnce(null) // no es oportunidad directa .mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // mapping lead → oportunidad const { res, status } = resFake() await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_lead_1' }), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ board: 'APARTADO' }), }) }) 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(prismaMock.leadOportunidad.findUnique).not.toHaveBeenCalled() expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() }) it('con apartadoMondayId (botón "Alta Enkontrol" desde Mis apartados) LIGA ese apartado sin crear otro', async () => { const { crearCliente } = await import('./clientes.controller.js') const { res, status, json } = resFake() await crearCliente(reqFake({ ...altaBase, apartadoMondayId: 'loc_apt_existente' }), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: { mondayApartadoId: 'loc_apt_existente' }, }) expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'loc_apt_existente' }) }) it('un fallo del funnel no rompe el alta (201 igual)', async () => { const { crearCliente } = await import('./clientes.controller.js') prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' }) prismaMock.leadCaptura.findUnique.mockResolvedValue(null) // apartar → NotFoundError const espia = vi.spyOn(console, 'error').mockImplementation(() => {}) const { res, status, json } = resFake() await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res) espia.mockRestore() expect(status).toHaveBeenCalledWith(201) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayApartadoId: null }) }) 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('alta progresiva — local', () => { beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data })) prismaMock.cliente.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal) prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' }) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) it('iniciarProgreso crea el alta en progreso (sin réplica externa)', 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: null }) expect(prismaMock.cliente.create).toHaveBeenCalledWith({ data: expect.objectContaining({ nombreCompleto: 'Ana Demo Progreso' }), }) }) it('iniciarProgreso 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) await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow() }) 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 }) }), ) }) it('actualizarProgreso guarda el avance del wizard localmente (mondaySync false)', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') const datosWizard = { datosGenerales: { nombreCompleto: 'Ana', celular: '6649990003' } } prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' }) const { res, json } = resFake() await actualizarProgreso({ body: datosWizard, params: { id: 'cli-1' } } as unknown as Request, res) expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: { payloadJson: JSON.stringify(datosWizard) }, }) expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false }) }) it('actualizarProgreso con id desconocido → 404', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue(null) await expect( actualizarProgreso({ body: {}, params: { id: 'no-existe' } } as unknown as Request, resFake().res), ).rejects.toThrow('Cliente no encontrado') }) it('actualizarProgreso rechaza un body que no sea objeto', async () => { const { actualizarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' }) await expect( actualizarProgreso({ body: 'no-objeto', params: { id: 'cli-1' } } as unknown as Request, resFake().res), ).rejects.toThrow() }) it('completarProgreso cierra el alta y mueve la oportunidad del funnel a Aparto', async () => { const { completarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', capturadoPorId: 'usr-9', mondayApartadoId: null, }) const altaCompleta = { datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' }, viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 }, leadMondayId: 'loc_op_9', } const { res, status, json } = resFake() await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cliente.update).toHaveBeenCalledWith({ where: { id: 'cli-1' }, data: expect.objectContaining({ proyecto: 'SENDA', unidadCodigo: 'U-999' }), }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }), }) // La ficha del apartado local se enriquece con lo capturado en el alta const enriquecimiento = prismaMock.leadCaptura.update.mock.calls.find( (c) => (c[0] as { where: { leadId: string } }).where.leadId !== 'loc_op_9', ) expect(enriquecimiento).toBeDefined() const datos = JSON.parse( (enriquecimiento![0] as { data: { datosJson: string } }).data.datosJson, ) as Record expect(datos).toMatchObject({ unidadCodigo: 'U-999', precioFinal: 1500000, enganche: 20000, estadoLabel: 'Pago Apartado' }) expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: expect.stringMatching(/^loc_/), }) }) it('completarProgreso 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' }) await expect( completarProgreso( reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }), resFake().res, ), ).rejects.toThrow() expect(prismaMock.cliente.update).not.toHaveBeenCalled() }) it('completarProgreso 404 si el progreso no existe', async () => { const { completarProgreso } = await import('./clientes.controller.js') prismaMock.cliente.findUnique.mockResolvedValue(null) await expect( completarProgreso(reqFake(altaBase, { id: 'no-existe' }), resFake().res), ).rejects.toThrow('Cliente no encontrado') }) })