- Migracion: columna LeadCaptura.telefonoNorm (ultimos 10 digitos) con
backfill de los 873 registros e indice
- buscarPorTelefono usa telefonoNorm exacto: la busqueda anterior
'telefono contains' fallaba con telefonos guardados con espacios
('+52 664 225 7841' no contiene '6642257841') y dejaba pasar duplicados
- Mutex en memoria por telefono/email: dos envios simultaneos ya no pasan
juntos la validacion
- convertirLead/apartar propagan telefonoNorm; email se guarda en minusculas
- 8 tests nuevos (normalizacion, dedup con espacios, lock)
498 lines
20 KiB
TypeScript
498 lines
20 KiB
TypeScript
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(),
|
|
},
|
|
automatizacionState: {
|
|
findUnique: vi.fn(),
|
|
upsert: vi.fn(),
|
|
},
|
|
recordatorioVendedor: {
|
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
cliente: {
|
|
findMany: vi.fn(),
|
|
},
|
|
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
|
}))
|
|
|
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
|
|
|
import { CrmLocalService, esIdLocal } from './crm-local.service.js'
|
|
|
|
const svc = new CrmLocalService()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => 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.automatizacionState.findUnique.mockResolvedValue(null)
|
|
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
|
})
|
|
|
|
describe('esIdLocal', () => {
|
|
it('distingue ids locales de ids heredados', () => {
|
|
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', 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,
|
|
}),
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe('CrmLocalService.crearAfluencia', () => {
|
|
it('crea la oportunidad local (board=OPORTUNIDADES, etapa Afluencia) con perfil en datosJson', 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<string, unknown> }
|
|
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
|
|
expect(prismaMock.leadOportunidad.create).toHaveBeenCalledWith({ data: { leadId: r.id, oportunidadId: r.id } })
|
|
})
|
|
|
|
it('con oportunidadId ACTUALIZA la fila local (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()
|
|
})
|
|
|
|
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 guarda la fecha de la cita en datosJson', 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, datosJson: JSON.stringify({ fechaCita: '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 heredado)', 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 }),
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('CrmLocalService.descartar', () => {
|
|
it('marca etapa terminal Perdido con motivo/submotivo', 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 },
|
|
})
|
|
})
|
|
|
|
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 y la liga', 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 },
|
|
})
|
|
})
|
|
|
|
it('si el lead ya fue convertido → 409', 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: JSON.stringify({
|
|
edad: 35,
|
|
estadoCivil: 'Casado',
|
|
tipoEmpleo: 'Formal',
|
|
rangoSalarial: 'B. 20,000-40,000',
|
|
tipoEconomia: 'Formal',
|
|
califica: 'Si',
|
|
loQuierePara: 'Para vivir',
|
|
}),
|
|
}
|
|
|
|
it('sin afluencia completa rechaza el apartado (regla de negocio)', async () => {
|
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
|
...filaOportunidad,
|
|
datosJson: '{"edad":35}',
|
|
})
|
|
await expect(svc.apartar('loc_op', 'u-1')).rejects.toThrow(/afluencia/i)
|
|
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('pone la oportunidad en Aparto y crea el apartado local (Pago 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' } }),
|
|
)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
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: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] },
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
|
|
describe('CrmLocalService.crearLead — dedup con etapas cerradas', () => {
|
|
it('el dedup por teléfono excluye etapas cerradas: limpieza/descartes/cancelados no bloquean la re-captura', async () => {
|
|
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
|
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data }))
|
|
const r = await svc.crearLead({ nombre: 'Reintento', telefono: '6641234567' }, 'u-1')
|
|
expect('duplicado' in r).toBe(false)
|
|
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('el dedup por email usa el mismo criterio (cualquier tablero, sin etapas cerradas)', async () => {
|
|
prismaMock.leadCaptura.findFirst
|
|
.mockResolvedValueOnce(null) // teléfono
|
|
.mockResolvedValueOnce(null) // email
|
|
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data }))
|
|
await svc.crearLead({ nombre: 'Reintento Mail', telefono: '', email: 'reintento@correo.com' }, 'u-1')
|
|
expect(prismaMock.leadCaptura.findFirst).toHaveBeenLastCalledWith({
|
|
where: {
|
|
OR: [{ email: 'reintento@correo.com' }],
|
|
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => {
|
|
it('soloPropio filtra leads por usuario y clientes por capturadoPorId', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
|
await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true })
|
|
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { AND: [{ usuarioId: 'u-9' }, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] },
|
|
}),
|
|
)
|
|
expect(prismaMock.cliente.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { AND: [{ capturadoPorId: 'u-9' }, { OR: [{ nombreCompleto: { contains: 'ana' } }, { celular: { contains: 'ana' } }] }] },
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('sin alcance la búsqueda sigue siendo global (roles de supervisión)', async () => {
|
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
|
await svc.buscarLocales('ana')
|
|
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { AND: [{}, { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] }] },
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('normalizaTelefono', () => {
|
|
it('quita formato y toma los últimos 10 dígitos', async () => {
|
|
const { normalizaTelefono } = await import('./crm-local.service.js')
|
|
expect(normalizaTelefono('+52 664 225 7841')).toBe('6642257841')
|
|
expect(normalizaTelefono('+526198790165')).toBe('6198790165')
|
|
expect(normalizaTelefono('+52 52 664 225 7841')).toBe('6642257841') // lada duplicada
|
|
expect(normalizaTelefono('4771234567')).toBe('4771234567')
|
|
})
|
|
|
|
it('conserva 7-9 dígitos; rechaza menos de 7 y vacíos', async () => {
|
|
const { normalizaTelefono } = await import('./crm-local.service.js')
|
|
expect(normalizaTelefono('12345678')).toBe('12345678')
|
|
expect(normalizaTelefono('123456')).toBeNull()
|
|
expect(normalizaTelefono('')).toBeNull()
|
|
expect(normalizaTelefono(null)).toBeNull()
|
|
expect(normalizaTelefono(undefined)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('CrmLocalService dedup normalizado (anti-duplicados)', () => {
|
|
it('crearLead guarda telefonoNorm aunque el teléfono venga con espacios', async () => {
|
|
const r = await svc.crearLead({ nombre: 'Guadalupe Avila', telefono: '+52 664 225 7841' }, 'u-1')
|
|
expect('duplicado' in r).toBe(false)
|
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ telefono: '+52 664 225 7841', telefonoNorm: '6642257841' }),
|
|
})
|
|
})
|
|
|
|
it('el dedup busca por telefonoNorm exacto (no por contains con espacios)', async () => {
|
|
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Guadalupe' })
|
|
const r = await svc.crearLead({ nombre: 'Guadalupe', telefono: '664 225 7841' })
|
|
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Guadalupe' } })
|
|
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({ telefonoNorm: '6642257841' }),
|
|
}),
|
|
)
|
|
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('crearAfluencia guarda telefonoNorm y dedup por teléfono normalizado', async () => {
|
|
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '+52 (664) 111-2233' }, 'u-2')
|
|
expect('duplicado' in r).toBe(false)
|
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ telefonoNorm: '6641112233' }),
|
|
})
|
|
})
|
|
|
|
it('email se guarda en minúsculas y el dedup lo busca también en minúsculas', async () => {
|
|
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
|
|
const r = await svc.crearLead({ nombre: 'Ana Mail', email: 'Ana@Correo.COM' }, 'u-1')
|
|
expect('duplicado' in r).toBe(false)
|
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ email: 'ana@correo.com' }),
|
|
})
|
|
})
|
|
|
|
it('convertirLead propaga telefonoNorm a la oportunidad nueva', async () => {
|
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
|
leadId: 'loc_lead',
|
|
oportunidadId: null,
|
|
usuarioId: 'u-1',
|
|
nombreLead: 'Ana Propaga',
|
|
telefono: '+52 664 225 7841',
|
|
telefonoNorm: '6642257841',
|
|
email: null,
|
|
origen: null,
|
|
desarrollo: null,
|
|
modelo: null,
|
|
datosJson: null,
|
|
})
|
|
await svc.convertirLead('loc_lead')
|
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ board: 'OPORTUNIDADES', telefonoNorm: '6642257841' }),
|
|
})
|
|
})
|
|
|
|
it('dos capturas simultáneas del mismo teléfono: sólo una crea (lock anti-carrera)', async () => {
|
|
// findFirst devuelve null la primera vez y "encuentra" la fila ya creada
|
|
// a partir de la segunda: sin lock, la carrera crearía dos filas.
|
|
const tienda = new Map<string, { leadId: string; nombreLead: string }>()
|
|
prismaMock.leadCaptura.findFirst.mockImplementation(async ({ where }) => {
|
|
const tel = where?.telefonoNorm as string | undefined
|
|
return tel ? (tienda.get(tel) ?? null) : null
|
|
})
|
|
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => {
|
|
tienda.set(data.telefonoNorm, { leadId: data.leadId, nombreLead: data.nombreLead })
|
|
return { id: 'row', ...data }
|
|
})
|
|
const [r1, r2] = await Promise.all([
|
|
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
|
|
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
|
|
])
|
|
const duplicados = [r1, r2].filter((r) => 'duplicado' in r).length
|
|
const creados = [r1, r2].filter((r) => !('duplicado' in r)).length
|
|
expect(creados).toBe(1)
|
|
expect(duplicados).toBe(1)
|
|
})
|
|
})
|