import { beforeEach, describe, expect, it, vi } from 'vitest' // ---------- Prisma mockeado (los tests no tocan la BD) ---------- const prismaMock = vi.hoisted(() => ({ leadCaptura: { create: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), }, leadOportunidad: { create: vi.fn(), findUnique: vi.fn(), }, syncPendiente: { create: vi.fn(), }, automatizacionState: { findUnique: vi.fn(), upsert: vi.fn(), }, cliente: { findMany: vi.fn(), }, $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) import { CrmLocalService, esIdLocal } from './crm-local.service.js' const svc = new CrmLocalService() /** Tipos de sync encolados, en orden, a partir de las llamadas al mock */ function tiposEncolados(): string[] { return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo) } function payloadEncolado(tipo: string): Record { const llamada = prismaMock.syncPendiente.create.mock.calls.find( (c) => (c[0] as { data: { tipo: string } }).data.tipo === tipo, ) if (!llamada) throw new Error(`No se encoló ${tipo}`) return JSON.parse((llamada[0] as { data: { payloadJson: string } }).data.payloadJson) as Record } beforeEach(() => { vi.clearAllMocks() prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'row-nueva', ...data })) prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // default: sin duplicados prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data) prismaMock.leadOportunidad.create.mockResolvedValue({}) prismaMock.syncPendiente.create.mockResolvedValue({}) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) }) describe('esIdLocal', () => { it('distingue ids locales de ids de Monday', () => { expect(esIdLocal('loc_123e4567-e89b-12d3-a456-426614174000')).toBe(true) expect(esIdLocal('1234567890')).toBe(false) }) }) describe('CrmLocalService.crearLead', () => { it('crea la captura board=LEADS etapa NUEVO pendienteSync y encola CREAR_LEAD + ASIGNAR_PERSONA', async () => { const r = await svc.crearLead( { nombre: 'Ana Prueba', telefono: '4771234567', origen: 'WhatsApp', desarrollo: 'SENDA', notas: 'Quiere 2R' }, 'u-1', ) expect('duplicado' in r).toBe(false) if ('duplicado' in r) return expect(r.id).toMatch(/^loc_/) expect(r.oportunidadId).toBeNull() expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ leadId: r.id, usuarioId: 'u-1', nombreLead: 'Ana Prueba', board: 'LEADS', etapa: 'NUEVO', origen: 'WhatsApp', desarrollo: 'SENDA', pendienteSync: true, }), }) expect(tiposEncolados()).toEqual(['CREAR_LEAD', 'ASIGNAR_PERSONA']) const payload = payloadEncolado('CREAR_LEAD') expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', telefono: '4771234567', origenLabel: 'WhatsApp' }) expect(payloadEncolado('ASIGNAR_PERSONA')).toEqual({ board: 'leads', usuarioId: 'u-1' }) // Todo quedó en una sola transacción (cambio local + cola atómicos) expect(prismaMock.$transaction).toHaveBeenCalledOnce() }) it('sin teléfono repetido no hay duplicado; con teléfono existente devuelve { duplicado }', async () => { prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Ana Prueba' }) const r = await svc.crearLead({ nombre: 'Ana Prueba', telefono: '4771234567' }) expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Ana Prueba' } }) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() }) }) describe('CrmLocalService.crearAfluencia', () => { it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson y encola CREAR_AFLUENCIA', async () => { const r = await svc.crearAfluencia( { nombre: 'Luis Demo', telefono: '6641112233', desarrollo: 'ATELIER', modelo: 'SOHO', edad: 35, credito: 'Infonavit', loQuierePara: 'Habitarla', }, 'u-2', ) expect('duplicado' in r).toBe(false) if ('duplicado' in r) return expect(r.id).toMatch(/^loc_/) expect(r.oportunidadId).toBe(r.id) // la afluencia ES la oportunidad const creada = prismaMock.leadCaptura.create.mock.calls[0]?.[0] as { data: Record } expect(creada.data).toMatchObject({ board: 'OPORTUNIDADES', etapa: 'Afluencia', modelo: 'SOHO', pendienteSync: true, }) // Perfilamiento completo en datosJson expect(JSON.parse(creada.data['datosJson'] as string)).toMatchObject({ edad: 35, credito: 'Infonavit' }) // Auto-mapping leadId = oportunidadId (como el flujo Monday) expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } }) expect(tiposEncolados()).toEqual(['CREAR_AFLUENCIA', 'ASIGNAR_PERSONA']) expect(payloadEncolado('CREAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', modeloLabel: 'SOHO', creditoLabel: 'Infonavit', loQuierePara: 'Habitarla', }) }) it('con oportunidadId ACTUALIZA la fila local y encola ACTUALIZAR_AFLUENCIA (no crea duplicado)', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'op-55', nombreLead: 'Luis Demo' }) const r = await svc.crearAfluencia({ nombre: 'Luis Demo', oportunidadId: 'op-55', califica: 'Si' }, 'u-2') expect(r).toEqual({ id: 'op-55', oportunidadId: 'op-55', actualizada: true }) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'op-55' }, data: expect.objectContaining({ etapa: 'Afluencia', pendienteSync: true }), }) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() expect(tiposEncolados()).toEqual(['ACTUALIZAR_AFLUENCIA']) expect(payloadEncolado('ACTUALIZAR_AFLUENCIA')['datos']).toMatchObject({ nombre: 'Luis Demo', calificaLabel: 'Si' }) }) it('con teléfono duplicado en OPORTUNIDADES devuelve { duplicado }', async () => { prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_op', nombreLead: 'Luis Demo' }) const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '6641112233' }) expect(r).toEqual({ duplicado: { id: 'loc_op', nombre: 'Luis Demo' } }) }) }) describe('CrmLocalService.cambiarEstado', () => { it('actualiza la etapa local y encola CAMBIO_ETAPA con board y label', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'OPORTUNIDADES' }) await svc.cambiarEstado('loc_1', 'Agendo Cita', 'OPORTUNIDADES', { fecha: '2026-09-10' }) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_1' }, data: { etapa: 'Agendo Cita', pendienteSync: true }, }) expect(payloadEncolado('CAMBIO_ETAPA')).toEqual({ board: 'oportunidades', label: 'Agendo Cita', fecha: '2026-09-10' }) }) it('sin fila local y sin sombra → 404', async () => { await expect(svc.cambiarEstado('loc_inexistente', 'Contactado', 'LEADS')).rejects.toMatchObject({ status: 404 }) }) it('con sombra crea la fila mínima (id Monday viejo) y encola igual', async () => { await svc.cambiarEstado('998877', 'Contactado', 'LEADS', { sombra: true, usuarioId: 'u-1' }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ leadId: '998877', board: 'LEADS', mondayItemId: '998877', pendienteSync: true }), }) expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA']) }) }) describe('CrmLocalService.descartar', () => { it('marca etapa terminal Perdido con motivo/submotivo y encola DESCARTE', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_2', nombreLead: 'Luis Demo' }) await svc.descartar('loc_2', 'Precio', { subcategoria: 'Enganche', comentario: 'Se le hizo caro' }) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_2' }, data: { etapa: 'Perdido', motivo: 'Precio', submotivo: 'Enganche', pendienteSync: true }, }) expect(payloadEncolado('DESCARTE')).toEqual({ fuente: 'oportunidad', motivo: 'Precio', nombreItem: 'Luis Demo', subcategoria: 'Enganche', comentario: 'Se le hizo caro', }) }) it('fuente lead: etapa terminal Descartado', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_3', nombreLead: 'Ana' }) await svc.descartar('loc_3', 'Timing', { fuente: 'lead' }) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_3' }, data: expect.objectContaining({ etapa: 'Descartado' }), }) }) }) describe('CrmLocalService.convertirLead', () => { const filaLead = { leadId: 'loc_lead', oportunidadId: null, usuarioId: 'u-1', nombreLead: 'Ana Prueba', telefono: '4771234567', email: null, origen: 'WhatsApp', desarrollo: 'SENDA', modelo: null, datosJson: '{"notas":null}', } it('marca el lead Calificado, crea la oportunidad en Agendo Cita, liga y encola CONVERSION', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue(filaLead) const op = await svc.convertirLead('loc_lead', 'u-1') expect(op.leadId).toMatch(/^loc_/) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_lead' }, data: { etapa: 'Calificado', oportunidadId: op.leadId, pendienteSync: true }, }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ leadId: op.leadId, board: 'OPORTUNIDADES', etapa: 'Agendo Cita', nombreLead: 'Ana Prueba', pendienteSync: true, }), }) expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: 'loc_lead', oportunidadId: op.leadId }, }) const payload = payloadEncolado('CONVERSION') expect(payload['oportunidadIdLocal']).toBe(op.leadId) expect(payload['datos']).toMatchObject({ nombre: 'Ana Prueba', estadoLabel: 'Agendo Cita', telefono: '4771234567' }) }) it('si el lead ya fue convertido → 409 como el flujo Monday', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue({ ...filaLead, oportunidadId: 'loc_op_previa' }) await expect(svc.convertirLead('loc_lead')).rejects.toMatchObject({ status: 409 }) expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() }) it('lead inexistente → 404', async () => { await expect(svc.convertirLead('loc_fantasma')).rejects.toMatchObject({ status: 404 }) }) }) describe('CrmLocalService.apartar', () => { const filaOportunidad = { leadId: 'loc_op', usuarioId: 'u-1', nombreLead: 'Luis Demo', telefono: '6641112233', email: 'luis@correo.com', desarrollo: 'ATELIER', modelo: 'SOHO', datosJson: '{"edad":35}', } it('pone la oportunidad en Aparto, crea el apartado local (Pago Apartado) y encola CAMBIO_ETAPA + CREAR_APARTADO', async () => { prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad) const apartado = await svc.apartar('loc_op', 'u-1') expect(apartado.leadId).toMatch(/^loc_/) expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ where: { leadId: 'loc_op' }, data: { etapa: 'Aparto', pendienteSync: true }, }) expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo', pendienteSync: true, }), }) // Dedup compatible con las automatizaciones expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { clave: 'apartado-creado:loc_op' } }), ) expect(tiposEncolados()).toEqual(['CAMBIO_ETAPA', 'CREAR_APARTADO']) expect(payloadEncolado('CREAR_APARTADO')['datos']).toMatchObject({ clienteNombre: 'Luis Demo', estadoLabel: 'Pago Apartado', telefono: '6641112233', email: 'luis@correo.com', }) }) it('idempotente: con el dedup escrito devuelve el apartado existente sin encolar de nuevo', async () => { prismaMock.leadCaptura.findUnique.mockImplementation(async ({ where }: { where: { leadId: string } }) => where.leadId === 'loc_op' ? filaOportunidad : { leadId: 'loc_ap_previo', board: 'APARTADO' }, ) prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:loc_op', valor: 'loc_ap_previo' }) const apartado = await svc.apartar('loc_op', 'u-1') expect(apartado.leadId).toBe('loc_ap_previo') expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled() expect(prismaMock.syncPendiente.create).not.toHaveBeenCalled() }) }) describe('CrmLocalService.buscarLocales', () => { it('combina LeadCaptura y Cliente con la forma del picker del alta', async () => { prismaMock.leadCaptura.findMany.mockResolvedValue([ { leadId: 'loc_1', nombreLead: 'Ana Prueba', telefono: '4771234567', email: null, etapa: 'Afluencia', origen: 'WhatsApp', board: 'OPORTUNIDADES', desarrollo: 'SENDA', modelo: 'SOHO', updatedAt: new Date('2026-09-05T10:00:00Z'), }, ]) prismaMock.cliente.findMany.mockResolvedValue([ { id: 'cli-9', nombreCompleto: 'Ana Cliente', celular: '6641112233', email: 'ana@correo.com', proyecto: 'ATELIER', createdAt: new Date('2026-09-04T10:00:00Z'), }, ]) const r = await svc.buscarLocales('ana') expect(r).toHaveLength(2) // Más reciente primero: la captura (sep-05) antes que el cliente (sep-04) expect(r[0]).toEqual({ id: 'loc_1', nombre: 'Ana Prueba', telefono: '4771234567', email: '', estado: 'Afluencia', origen: 'WhatsApp', fuente: 'oportunidad', desarrollo: 'SENDA', modeloInteres: 'SOHO', }) expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' }) expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }), ) }) })