Files
FRASA-CAS/api-gateway/src/services/contactos-local.service.test.ts
Consult AS Technology 8a2584438d fix(auditoria general): 25 errores encontrados en la revision modulo por modulo
SEGURIDAD (agente IA):
- Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican
  propiedad (antes cualquier usuario leia/continuaba la conversacion de otro).
- /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar
  documentos de la base de conocimiento).

DATOS / CORRUPCION:
- Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead
  fantasma "Registro previo (CRM anterior)" en el embudo.
- Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma.
- Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su
  asesor real segun el Excel, el resto al admin) y el motor de recordatorios
  cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios
  se registraban 'sin-destinatario' y no los veia nadie.
- Backups: script reescrito para el host real (sqlite .backup + uploads + env
  en /root/backups) y cron instalado (antes: script que asumia docker y sin
  cron programado = cero respaldos).

REPORTES / METRICAS:
- Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion
  Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes
  41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no
  existe); tasa de descarte ya compara el mismo periodo.
- Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los
  descartados/perdidos ya no reciben score.
- ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago
  que vence hoy.

UI / FLUJOS:
- mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion);
  confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban).
- alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas;
  toast de exito solo cuando el API confirma; prefill de apartado trae
  direccion, estado civil y fecha de nacimiento desde el alta.
- Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el
  API esperaba 'usuarioId') y 'hasta' incluye todo el dia.
- Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o
  se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de
  tiempo).
- Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende
  ?cotizacion=<id> para retomar desde la liga.
- Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados.
- Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe).
- Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real
  (antes limitado a 3 proyectos que no cubrian la cartera).

Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de
afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza +
89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
2026-09-24 10:54:09 +00:00

436 lines
15 KiB
TypeScript

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<string, unknown> & { 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<string, unknown> & { 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 paginación se aplica DESPUÉS de fusionar fuentes, así
// que el servicio pide el universo completo y corta en memoria
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 trae la fila más vieja (orden desc por fecha: la 101 va
// primero en la página 1)
const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.contactos[0]?.id).toBe('c1')
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)
})
})