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:
211
api-gateway/src/services/contactos-local.service.test.ts
Normal file
211
api-gateway/src/services/contactos-local.service.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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 la forma de ContactoMonday + 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()
|
||||
|
||||
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()
|
||||
expect(lista).toHaveLength(1)
|
||||
expect(lista[0]?.id).toBe('c1')
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
Teléfono: '4771234567',
|
||||
Email: 'juan@correo.mx',
|
||||
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', Teléfono: '6649998877', Estado: 'Afluencia' })
|
||||
expect(detalle.propiedad).toBeNull()
|
||||
expect(detalle.relaciones.oportunidades).toHaveLength(1)
|
||||
expect(detalle.historial.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user