import { beforeEach, describe, expect, it, vi } from 'vitest' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- const prismaMock = vi.hoisted(() => ({ cliente: { findFirst: vi.fn(), findMany: vi.fn(), }, leadCaptura: { findUnique: vi.fn(), findMany: vi.fn(), }, cotizacion: { findMany: vi.fn(), }, documento: { findMany: vi.fn(), }, usuario: { findMany: vi.fn(), }, })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) import { detalleContactoLocal, listarContactosLocal } from './contactos-local.service.js' function clienteFake(parcial: Record & { id: string }) { return { nombreCompleto: `Cliente ${parcial.id}`, curp: 'CURP123', rfc: 'RFC123', email: null, celular: null, proyecto: '', unidadCodigo: null, payloadJson: '{}', mondayContactoId: null, mondayApartadoId: null, capturadoPorId: null, createdAt: new Date('2026-09-01T10:00:00'), ...parcial, } } function capturaFake(parcial: Record & { leadId: string }) { return { id: `row-${parcial.leadId}`, nombreLead: `Persona ${parcial.leadId}`, telefono: null, email: null, origen: null, desarrollo: null, modelo: null, board: 'OPORTUNIDADES', etapa: 'Afluencia', usuarioId: 'u-1', datosJson: null, mondayItemId: null, pendienteSync: true, createdAt: new Date('2026-09-02T10:00:00'), updatedAt: new Date('2026-09-03T10:00:00'), ...parcial, } } beforeEach(() => { vi.clearAllMocks() prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.cliente.findFirst.mockResolvedValue(null) prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.cotizacion.findMany.mockResolvedValue([]) prismaMock.documento.findMany.mockResolvedValue([]) prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', nombre: 'Martin Zavala' }]) }) describe('listarContactosLocal', () => { it('arma la lista desde Cliente + capturas (OPORTUNIDADES/APARTADO) con fuente local', async () => { prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA' }), ]) prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Pago Apartado', board: 'APARTADO' }), ]) const lista = (await listarContactosLocal()).contactos expect(lista).toHaveLength(2) expect(lista[0]).toMatchObject({ id: 'loc_1', // updatedAt más reciente primero nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Pago Apartado', fuente: 'local', }) expect(lista[1]).toMatchObject({ id: 'c1', nombre: 'JUAN PÉREZ', telefono: '4771234567', email: 'juan@correo.mx', estado: 'Cliente', fuente: 'local', }) }) it('deduplica por teléfono (gana el Cliente: tiene la ficha completa)', async () => { prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'c1', celular: '+52 477 123 4567' }), ]) prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_1', telefono: '4771234567' }), ]) const lista = (await listarContactosLocal()).contactos expect(lista).toHaveLength(1) expect(lista[0]?.id).toBe('c1') }) it('deduplica por nombre a los registros sin teléfono ni email (gana la ficha con más datos)', async () => { // Alta fallida: Cliente hueco (payload vacío, sin teléfono ni email) con el // mismo nombre que su ficha real prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'hueco', nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA', payloadJson: '{}', createdAt: new Date('2026-09-11T10:00:00'), }), clienteFake({ id: 'completo', nombreCompleto: 'Luis Enrique Salgado Zavaleta', celular: '+52 5586880444', email: 'luisenriquesalgadozavaleta@gmail.com', curp: 'SAZL990504HDFLVS06', rfc: 'SAZL990504KY0', proyecto: 'SOLANA', unidadCodigo: '9', payloadJson: '{"datosGenerales":{"estadoCivil":"SOLTERO"}}', }), ]) const lista = (await listarContactosLocal()).contactos expect(lista).toHaveLength(1) expect(lista[0]).toMatchObject({ id: 'completo', nombre: 'Luis Enrique Salgado Zavaleta', telefono: '+52 5586880444', email: 'luisenriquesalgadozavaleta@gmail.com', estado: 'Cliente', }) }) it('fusiona el registro hueco aunque llegue después de la ficha completa', async () => { prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'completo', nombreCompleto: 'GRACIELA BAUTISTA VELASCO', celular: '+52 6641159092', email: 'gbautista1304@gmail.com', }), clienteFake({ id: 'hueco', nombreCompleto: 'Graciela Bautista Velasco', payloadJson: '{}' }), ]) const lista = (await listarContactosLocal()).contactos expect(lista).toHaveLength(1) expect(lista[0]?.id).toBe('completo') }) it('no fusiona a dos personas distintas con el mismo nombre si tienen teléfonos distintos', async () => { prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567' }), clienteFake({ id: 'c2', nombreCompleto: 'JUAN PÉREZ', celular: '6649998877' }), ]) const lista = (await listarContactosLocal()).contactos expect(lista.map((c) => c.id).sort()).toEqual(['c1', 'c2']) }) it('un Cliente hueco se fusiona con la captura del mismo nombre (gana la que trae más datos)', async () => { prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'hueco', nombreCompleto: 'Guadalupe', curp: '', rfc: '', payloadJson: '{}', createdAt: new Date('2026-09-15T10:00:00'), }), ]) prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '+52 664 222 6090', email: 'lupisintj1@hotmail.com', etapa: 'Contactado', }), ]) const lista = (await listarContactosLocal()).contactos expect(lista).toHaveLength(1) expect(lista[0]).toMatchObject({ id: 'loc_g', telefono: '+52 664 222 6090', email: 'lupisintj1@hotmail.com', estado: 'Contactado', }) }) it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => { // 101 clientes: la página 1 llena 100 y detecta que hay más prismaMock.cliente.findMany.mockResolvedValue( Array.from({ length: 101 }, (_, i) => clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }), ), ) const pagina1 = await listarContactosLocal(undefined, 1) expect(pagina1.pagina).toBe(1) expect(pagina1.porPagina).toBe(100) expect(pagina1.contactos).toHaveLength(100) expect(pagina1.hayMas).toBe(true) // La página 2 recibe el siguiente bloque (skip=100, take=101) prismaMock.cliente.findMany.mockResolvedValue([ clienteFake({ id: 'c101', celular: '4770000100' }), ]) const pagina2 = await listarContactosLocal(undefined, 2) expect(pagina2.pagina).toBe(2) expect(pagina2.contactos).toHaveLength(1) expect(pagina2.hayMas).toBe(false) }) }) describe('detalleContactoLocal', () => { it('null si el id no existe en ninguna tabla local', async () => { expect(await detalleContactoLocal('nope')).toBeNull() }) it('por cliente.id: contacto + propiedad + relaciones + historial derivado de lo local', async () => { const cliente = clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567', email: 'juan@correo.mx', proyecto: 'SENDA', unidadCodigo: 'SENDA-101', mondayApartadoId: 'loc_a1', capturadoPorId: 'u-1', createdAt: new Date('2026-09-01T10:00:00'), }) prismaMock.cliente.findFirst.mockResolvedValue(cliente) prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_a1', board: 'APARTADO', etapa: 'Pago Apartado', telefono: '4771234567' }), capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Aparto', telefono: '4771234567', desarrollo: 'SENDA', modelo: '1R' }), capturaFake({ leadId: 'loc_x', board: 'LEADS', etapa: 'NUEVO', telefono: '9990001111' }), // otro teléfono: fuera ]) prismaMock.cotizacion.findMany.mockResolvedValue([ { id: 'cot1', unidadCodigo: 'SENDA-101', tipoPropuesta: 'BANCARIO', precioFinal: 4_400_000, vendedorId: 'u-1', createdAt: new Date('2026-08-30T10:00:00'), }, ]) prismaMock.documento.findMany.mockResolvedValue([ { id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') }, ]) const detalle = await detalleContactoLocal('c1') expect(detalle).not.toBeNull() if (detalle === null) return expect(detalle.contacto).toMatchObject({ nombre: 'JUAN PÉREZ', telefono: '4771234567', email: 'juan@correo.mx', estado: 'Cliente', curp: 'CURP123', rfc: 'RFC123', }) expect(detalle.propiedad).toMatchObject({ id: 'loc_a1', unidadCodigo: 'SENDA-101', desarrollo: 'SENDA', estado: 'Pago Apartado', precioFinal: 4_400_000, atendidoPor: 'Martin Zavala', }) expect(detalle.relaciones.oportunidades).toHaveLength(1) expect(detalle.relaciones.oportunidades[0]).toMatchObject({ id: 'loc_o1', estado: 'Aparto', atendidoPor: 'Martin Zavala', detalle: 'SENDA · 1R', }) expect(detalle.relaciones.apartados[0]).toMatchObject({ id: 'loc_a1', detalle: 'SENDA-101' }) expect(detalle.relaciones.leads).toHaveLength(0) // el de otro teléfono no entra const detalles = detalle.historial.map((h) => h.detalle) expect(detalles).toContain('Alta de cliente registrada (SENDA-101)') expect(detalles).toContain('Documento subido (INE)') expect(detalles).toContain('Cotización SENDA-101 (BANCARIO)') expect(detalles.some((d) => d.startsWith('Captura en la app'))).toBe(true) }) it('por leadId (loc_*): contacto desde la captura, sin propiedad', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue( capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877', etapa: 'Afluencia' }), ) prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_1', nombreLead: 'ANA DEMO', telefono: '6649998877' }), ]) const detalle = await detalleContactoLocal('loc_1') expect(detalle).not.toBeNull() if (detalle === null) return expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Afluencia' }) expect(detalle.propiedad).toBeNull() expect(detalle.relaciones.oportunidades).toHaveLength(1) expect(detalle.historial.length).toBeGreaterThan(0) }) it('completa el perfil con el payloadJson del alta (estado civil, nacimiento, actividad, dirección)', async () => { prismaMock.cliente.findFirst.mockResolvedValue( clienteFake({ id: 'c1', nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA', celular: '5586880444', email: 'luis@correo.mx', curp: '', rfc: '', payloadJson: JSON.stringify({ datosGenerales: { estadoCivil: 'SOLTERO', fechaNacimiento: '1999-05-04', actividadOcupacion: 'PILOTO AVIADOR', direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA', }, empleo: { compania: 'FRASA', direccion: 'AV COLINA DE SAN PABLO' }, // El co-acreditado repite los campos vacíos: no debe pisar los reales coacreditado: { tieneCoacreditado: false, generales: { estadoCivil: '', fechaNacimiento: '', actividadOcupacion: '', direccion: '' } }, }), }), ) const detalle = await detalleContactoLocal('c1') expect(detalle?.contacto).toMatchObject({ estadoCivil: 'SOLTERO', fechaNacimiento: '1999-05-04', actividadEconomica: 'PILOTO AVIADOR', direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA', }) }) it('toma el perfilamiento del datosJson de la captura cuando no hay Cliente', async () => { const captura = capturaFake({ leadId: 'loc_1', nombreLead: 'RAMON MORALES', telefono: '6643535353', email: 'ramon.morales@gmail.com', datosJson: JSON.stringify({ edad: 41, estadoCivil: 'Casado(a)', ocupacion: 'empleado', rangoSalarial: '50000', }), }) prismaMock.leadCaptura.findUnique.mockResolvedValue(captura) prismaMock.leadCaptura.findMany.mockResolvedValue([captura]) const detalle = await detalleContactoLocal('loc_1') expect(detalle?.contacto).toMatchObject({ nombre: 'RAMON MORALES', estadoCivil: 'Casado(a)', actividadEconomica: 'empleado', }) // No se inventa fecha de nacimiento a partir de la edad expect(detalle?.contacto['fechaNacimiento']).toBe('') expect(detalle?.contacto['direccion']).toBe('') }) it('incluye los documentos del registro hueco de la misma persona (no se pierden al fusionar)', async () => { prismaMock.cliente.findFirst.mockResolvedValue( clienteFake({ id: 'c1', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: '6643998451', email: 'daleman@frasa.com.mx', }), ) prismaMock.cliente.findMany.mockResolvedValue([ { id: 'hueco', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: null, email: null }, { id: 'otro', nombreCompleto: 'OTRA PERSONA', celular: null, email: null }, ]) prismaMock.documento.findMany.mockResolvedValue([ { id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') }, ]) const detalle = await detalleContactoLocal('c1') expect(prismaMock.documento.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { clienteId: { in: ['c1', 'hueco'] } } }), ) expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true) }) it('si la ficha visible es la captura, también se muestran los documentos de su Cliente hueco', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue( capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }), ) prismaMock.leadCaptura.findMany.mockResolvedValue([ capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }), ]) prismaMock.cliente.findMany.mockResolvedValue([ { id: 'hueco', nombreCompleto: 'Guadalupe', celular: null, email: null }, ]) prismaMock.documento.findMany.mockResolvedValue([ { id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') }, ]) const detalle = await detalleContactoLocal('loc_g') expect(prismaMock.documento.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { clienteId: { in: ['hueco'] } } }), ) expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true) }) })