feat(modo-local): fallback completo sin Monday + cola de sincronización
- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes
Env MODO_LOCAL_FORZADO=true para testing sin Monday.
This commit is contained in:
368
api-gateway/src/services/crm-local.service.test.ts
Normal file
368
api-gateway/src/services/crm-local.service.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
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<unknown>[]) => 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<string, unknown> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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.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<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 (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' } }] } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user