Files
FRASA-CAS/api-gateway/src/controllers/clientes.controller.test.ts
Consult AS Technology 60649890eb 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.
2026-09-06 08:08:56 +00:00

377 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
actividadEconomicaLabel,
checkboxParaTipoDocumento,
perfilDemografico,
rangoEdadLabel,
} from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
update: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
syncPendiente: {
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
crearApartado: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
moverAApartoConDedup: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => {
it('mapea los tipos con checkbox al título de la columna', () => {
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
expect(checkboxParaTipoDocumento('COMPROBANTE')).toBe('Comprobante')
expect(checkboxParaTipoDocumento('COMPROBANTE_DOMICILIO')).toBe('Comprobante')
})
it('normaliza mayúsculas y espacios', () => {
expect(checkboxParaTipoDocumento(' curp ')).toBe('CURP')
expect(checkboxParaTipoDocumento('comprobante_domicilio')).toBe('Comprobante')
})
it('INE y otros tipos no tienen checkbox (null)', () => {
expect(checkboxParaTipoDocumento('INE')).toBeNull()
expect(checkboxParaTipoDocumento('ACTA_NACIMIENTO')).toBeNull()
expect(checkboxParaTipoDocumento('OTRO')).toBeNull()
})
})
describe('rangoEdadLabel', () => {
const hoy = new Date('2026-08-22T12:00:00')
it('deriva el rango del CRM a partir de la fecha de nacimiento', () => {
expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años
expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy
expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40')
expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo
expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40')
})
it('devuelve undefined con fechas inválidas o ausentes', () => {
expect(rangoEdadLabel(undefined, hoy)).toBeUndefined()
expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined()
expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined()
})
})
describe('perfilDemografico (alta → Contacto Monday)', () => {
it('mapea los valores del formato a los labels del CRM', () => {
const perfil = perfilDemografico({
estadoCivil: 'UNION_LIBRE',
tipoVivienda: 'FAMILIAR',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-05-10',
})
expect(perfil.estadoCivilLabel).toBe('Union Libre')
expect(perfil.viviendaActualLabel).toBe('Comparte con familia')
expect(perfil.propositoCompraLabel).toBe('Habitarla')
expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40')
expect(perfil.fechaNacimiento).toBe('1990-05-10')
})
it('omite campos vacíos o desconocidos (no inventa datos)', () => {
expect(perfilDemografico({})).toEqual({})
expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({})
})
})
describe('actividadEconomicaLabel', () => {
it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => {
expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado')
expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina
expect(actividadEconomicaLabel(undefined)).toBeUndefined()
})
})
describe('crearClienteSchema', () => {
const base = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
it('acepta un alta sin ubicación (opcional en el formato web)', async () => {
const { crearClienteSchema } = await import('./clientes.controller.js')
expect(() => crearClienteSchema.parse(base)).not.toThrow()
})
it('sigue exigiendo nombre, curp, rfc y proyecto', async () => {
const { crearClienteSchema } = await import('./clientes.controller.js')
expect(() =>
crearClienteSchema.parse({ datosGenerales: { nombreCompleto: '', curp: '', rfc: '' }, viviendaCredito: {} }),
).toThrow()
})
})
describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () => {
// Import diferido para que los vi.mock de arriba apliquen al controller
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
viviendaCredito: { proyecto: 'SENDA' },
}
const reqFake = (body: unknown): Request => ({ body } as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
mondayMock.crearContacto.mockResolvedValue('cont-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
})
it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
})
it('sin leadMondayId no toca el funnel', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status } = resFake()
await crearCliente(reqFake(altaBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(funnelMock.moverAApartoConDedup).not.toHaveBeenCalled()
})
it('un fallo del funnel no rompe el alta (201 igual)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
})
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
await crearCliente({ body: altaBase, usuarioToken: { sub: 'usr-1' } } as unknown as Request, resFake().res)
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
)
await crearCliente(reqFake(altaBase), resFake().res)
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
)
})
})
describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => {
it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
})
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
const { formaPagoLabel } = await import('./clientes.controller.js')
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
expect(formaPagoLabel([])).toBeUndefined()
expect(formaPagoLabel(undefined)).toBeUndefined()
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
})
})
describe('crearCliente — Apartado con ficha completa', () => {
const altaConVenta = {
datosGenerales: {
nombreCompleto: 'Cliente Demo',
curp: 'CURP123',
rfc: 'RFC123',
email: 'cli@correo.com',
celular: '6641112233',
},
viviendaCredito: {
proyecto: 'SENDA',
ubicacion: 'U-101',
valorVenta: 1500000,
enganche: 20000,
tiposCredito: ['INFONAVIT'],
},
}
const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request =>
({ body, usuarioToken } as unknown as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
})
it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
montoApartado: 20000,
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'cli@correo.com',
curp: 'CURP123',
precioFinal: 1500000,
enganche: 20000,
formaPagoLabel: 'Infonavit',
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
}),
)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' })
})
it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => {
const { crearCliente } = await import('./clientes.controller.js')
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 }))
})
it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => {
const { crearCliente } = await import('./clientes.controller.js')
await crearCliente(reqFake(altaConVenta), resFake().res)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined }))
})
})
describe('réplica diferida (encolado cuando Monday falla)', () => {
const altaConVenta = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6641112233' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-101', valorVenta: 1500000, enganche: 20000 },
}
const reqFake = (body: unknown): Request => ({ body } as Request)
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
function tiposEncolados(): string[] {
return prismaMock.syncPendiente.create.mock.calls.map((c) => (c[0] as { data: { tipo: string } }).data.tipo)
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.syncPendiente.create.mockResolvedValue({})
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('falla crearContacto: 201 con ids null y CREAR_CONTACTO + CREAR_APARTADO encolados', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_CONTACTO', 'CREAR_APARTADO'])
// El payload del Contacto lleva los datos del alta (el worker no necesita nada más)
const llamadaContacto = prismaMock.syncPendiente.create.mock.calls[0]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaContacto.data.payloadJson)['datos']).toMatchObject({
nombre: 'Cliente Demo',
estadoLabel: 'CLIENTE',
curp: 'CURP123',
})
expect(llamadaContacto.data.referenciaLocal).toBe('cli-1')
// El del Apartado referencia al cliente para ligar mondayApartadoId al sincronizar
const llamadaApartado = prismaMock.syncPendiente.create.mock.calls[1]?.[0] as {
data: { payloadJson: string; referenciaLocal: string }
}
expect(JSON.parse(llamadaApartado.data.payloadJson)).toMatchObject({
clienteId: 'cli-1',
datos: { clienteNombre: 'Cliente Demo', unidadCodigo: 'U-101', estadoLabel: 'Pago Apartado' },
})
})
it('falla solo el Apartado: conserva mondayContactoId (lo persiste) y encola CREAR_APARTADO', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await crearCliente(reqFake(altaConVenta), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayContactoId: 'cont-1', mondayApartadoId: null })
expect(tiposEncolados()).toEqual(['CREAR_APARTADO']) // el Contacto ya existe: no se duplica
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1', mondayApartadoId: null },
})
})
it('falla moverAApartoConDedup: encola APARTO_OPORTUNIDAD con el leadMondayId', async () => {
const { crearCliente } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockRejectedValue(new Error('Monday caído'))
const { res, status } = resFake()
await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(tiposEncolados()).toEqual(['APARTO_OPORTUNIDAD'])
expect(prismaMock.syncPendiente.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tipo: 'APARTO_OPORTUNIDAD',
payloadJson: JSON.stringify({ leadId: 'lead-9' }),
referenciaLocal: 'lead-9',
}),
})
})
})