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.
164 lines
7.3 KiB
TypeScript
164 lines
7.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// ---------- Prisma mockeado ----------
|
|
|
|
const prismaMock = vi.hoisted(() => ({
|
|
leadCaptura: { findMany: vi.fn() },
|
|
cliente: { findMany: vi.fn() },
|
|
cotizacion: { findMany: vi.fn() },
|
|
unidad: { findMany: vi.fn() },
|
|
}))
|
|
|
|
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
|
|
|
import { buscar } from './buscar.service.js'
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
|
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
|
prismaMock.unidad.findMany.mockResolvedValue([])
|
|
})
|
|
|
|
describe('buscar (GET /api/buscar)', () => {
|
|
it('término vacío retorna [] sin consultar la base', async () => {
|
|
const r = await buscar('')
|
|
expect(r).toEqual([])
|
|
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('término con solo espacios también retorna []', async () => {
|
|
const r = await buscar(' ')
|
|
expect(r).toEqual([])
|
|
expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('consulta los 4 modelos con el patrón contains', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
|
{ leadId: 'l1', nombreLead: 'María López', telefono: '4771112233', email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
|
|
])
|
|
prismaMock.cliente.findMany.mockResolvedValue([
|
|
{ id: 'c1', nombreCompleto: 'María López', curp: '', rfc: '', email: 'maria@x.com', celular: '4771112233', proyecto: 'DIAGONAL', createdAt: new Date() },
|
|
])
|
|
prismaMock.cotizacion.findMany.mockResolvedValue([])
|
|
prismaMock.unidad.findMany.mockResolvedValue([])
|
|
|
|
const r = await buscar('María')
|
|
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledTimes(1)
|
|
expect(prismaMock.cliente.findMany).toHaveBeenCalledTimes(1)
|
|
expect(prismaMock.cotizacion.findMany).toHaveBeenCalledTimes(1)
|
|
expect(prismaMock.unidad.findMany).toHaveBeenCalledTimes(1)
|
|
expect(r.length).toBe(2)
|
|
expect(r[0]?.tipo).toBe('lead')
|
|
expect(r[1]?.tipo).toBe('contacto')
|
|
})
|
|
|
|
it('aplica take: limite por modelo', async () => {
|
|
await buscar('test', 5)
|
|
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ take: 5 }),
|
|
)
|
|
})
|
|
|
|
it('prioriza leads sobre contactos, contactos sobre oportunidades, oportunidades sobre unidades', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
|
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
|
|
])
|
|
prismaMock.cliente.findMany.mockResolvedValue([
|
|
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
|
|
])
|
|
prismaMock.cotizacion.findMany.mockResolvedValue([
|
|
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
|
|
])
|
|
prismaMock.unidad.findMany.mockResolvedValue([
|
|
{ id: 'u1', codigo: 'X', desarrollo: 'SENDA', modelo: null, status: null, updatedAt: new Date() },
|
|
])
|
|
|
|
const r = await buscar('X')
|
|
expect(r.map((i) => i.tipo)).toEqual(['lead', 'contacto', 'oportunidad', 'unidad'])
|
|
})
|
|
|
|
it('mezcla los resultados y limita al total indicado', async () => {
|
|
const leads = Array.from({ length: 10 }, (_, i) => ({
|
|
leadId: `l${i}`, nombreLead: `L${i}`, telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date(),
|
|
}))
|
|
const clientes = Array.from({ length: 10 }, (_, i) => ({
|
|
id: `c${i}`, nombreCompleto: `C${i}`, curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date(),
|
|
}))
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue(leads)
|
|
prismaMock.cliente.findMany.mockResolvedValue(clientes)
|
|
|
|
const r = await buscar('x', 15)
|
|
expect(r).toHaveLength(15)
|
|
// Primeros 10 son leads (prioridad), siguientes 5 son contactos
|
|
expect(r.filter((i) => i.tipo === 'lead')).toHaveLength(10)
|
|
expect(r.filter((i) => i.tipo === 'contacto')).toHaveLength(5)
|
|
})
|
|
|
|
it('lead: detalle muestra teléfono · email, o origen como fallback', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
|
{ leadId: 'l1', nombreLead: 'Ana', telefono: '4771112233', email: 'ana@x.com', origen: null, board: 'LEADS', updatedAt: new Date() },
|
|
])
|
|
const r = await buscar('Ana')
|
|
expect(r[0]?.detalle).toBe('4771112233 · ana@x.com')
|
|
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
|
{ leadId: 'l2', nombreLead: 'Bob', telefono: null, email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() },
|
|
])
|
|
const r2 = await buscar('Bob')
|
|
expect(r2[0]?.detalle).toBe('Facebook')
|
|
})
|
|
|
|
it('contacto: detalle muestra email · celular · proyecto', async () => {
|
|
prismaMock.cliente.findMany.mockResolvedValue([
|
|
{ id: 'c1', nombreCompleto: 'Luis', curp: '', rfc: '', email: 'luis@x.com', celular: '4772223333', proyecto: 'SENDA', createdAt: new Date() },
|
|
])
|
|
const r = await buscar('Luis')
|
|
expect(r[0]?.detalle).toBe('luis@x.com · 4772223333 · SENDA')
|
|
})
|
|
|
|
it('oportunidad: detalle muestra unidadCodigo · desarrollo, o tipoPropuesta como fallback', async () => {
|
|
prismaMock.cotizacion.findMany.mockResolvedValue([
|
|
{ id: 'ct1', clienteNombre: 'Carlos', clienteEmail: null, unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', tipoPropuesta: null, createdAt: new Date() },
|
|
])
|
|
const r = await buscar('Carlos')
|
|
expect(r[0]?.detalle).toBe('U-101 · DIAGONAL')
|
|
|
|
prismaMock.cotizacion.findMany.mockResolvedValue([
|
|
{ id: 'ct2', clienteNombre: 'Diana', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: 'CONTADO', createdAt: new Date() },
|
|
])
|
|
const r2 = await buscar('Diana')
|
|
expect(r2[0]?.detalle).toBe('CONTADO')
|
|
})
|
|
|
|
it('unidad: detalle muestra desarrollo · modelo · status', async () => {
|
|
prismaMock.unidad.findMany.mockResolvedValue([
|
|
{ id: 'u1', codigo: 'U-202', desarrollo: 'ESTANZA', modelo: 'SOHO', status: 'Disponible', updatedAt: new Date() },
|
|
])
|
|
const r = await buscar('U-202')
|
|
expect(r[0]?.detalle).toBe('ESTANZA · SOHO · Disponible')
|
|
})
|
|
|
|
it('urls de navegación son correctas por tipo', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
|
{ leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() },
|
|
])
|
|
prismaMock.cliente.findMany.mockResolvedValue([
|
|
{ id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() },
|
|
])
|
|
prismaMock.cotizacion.findMany.mockResolvedValue([
|
|
{ id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() },
|
|
])
|
|
prismaMock.unidad.findMany.mockResolvedValue([
|
|
{ id: 'u1', codigo: 'X', desarrollo: null, modelo: null, status: null, updatedAt: new Date() },
|
|
])
|
|
|
|
const r = await buscar('X')
|
|
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/')
|
|
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/')
|
|
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
|
|
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
|
})
|
|
})
|