feat(purga): Monday eliminado completamente del sistema
14 servicios eliminados, 7 controllers local-only, crons locales, config sin MONDAY_*, frontend sin banners/avisos Monday. 552 tests API + 7 apps construidas.
This commit is contained in:
@@ -1,45 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import {
|
||||
actividadEconomicaLabel,
|
||||
checkboxParaTipoDocumento,
|
||||
perfilDemografico,
|
||||
rangoEdadLabel,
|
||||
} from './clientes.controller.js'
|
||||
import { checkboxParaTipoDocumento, formaPagoLabel } from './clientes.controller.js'
|
||||
|
||||
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
cliente: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
leadCaptura: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
leadOportunidad: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
automatizacionState: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
crearContacto: vi.fn(),
|
||||
crearApartado: vi.fn(),
|
||||
}))
|
||||
|
||||
const funnelMock = vi.hoisted(() => ({
|
||||
moverAApartoConDedup: vi.fn(),
|
||||
$transaction: 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', () => {
|
||||
describe('checkboxParaTipoDocumento (tipos de documento del expediente)', () => {
|
||||
it('mapea los tipos con checkbox al nombre del checkbox', () => {
|
||||
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
|
||||
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
|
||||
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
|
||||
@@ -59,50 +54,26 @@ describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', ()
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
describe('formaPagoLabel (tipos de crédito del alta)', () => {
|
||||
it('mapea los 8 valores del enum del formato', 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('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()
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,53 +96,141 @@ describe('crearClienteSchema', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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' },
|
||||
}
|
||||
// ---------- Alta local (POST /api/clientes) ----------
|
||||
|
||||
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 }
|
||||
}
|
||||
const altaBase = {
|
||||
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
|
||||
viviendaCredito: { proyecto: 'SENDA' },
|
||||
}
|
||||
|
||||
const reqFake = (body: unknown, params: Record<string, string> = {}, usuarioToken?: { sub: string }): Request =>
|
||||
({ body, params, 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 }
|
||||
}
|
||||
|
||||
const oportunidadLocal = {
|
||||
leadId: 'loc_op_9',
|
||||
board: 'OPORTUNIDADES',
|
||||
nombreLead: 'Lead Prueba',
|
||||
usuarioId: 'u-1',
|
||||
telefono: '4771234567',
|
||||
email: null,
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'SOHO',
|
||||
datosJson: null,
|
||||
}
|
||||
|
||||
describe('crearCliente (POST /api/clientes) — alta local', () => {
|
||||
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')
|
||||
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal)
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
|
||||
it('con leadMondayId mueve la oportunidad a Aparto (con dedup) antes de replicar en Monday', async () => {
|
||||
it('201: persiste el Cliente en SQLite y responde mondayContactoId null', 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)
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCliente(reqFake(altaBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
|
||||
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ nombreCompleto: 'Cliente Demo', proyecto: 'SENDA' }),
|
||||
})
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: null, mondayApartadoId: null })
|
||||
})
|
||||
|
||||
it('con leadMondayId mueve la oportunidad a Aparto y crea el apartado local (dedup)', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
// El id del picker es una oportunidad directa (modelo nuevo)
|
||||
prismaMock.leadOportunidad.findUnique
|
||||
.mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // buscarPorOportunidadId
|
||||
.mockResolvedValue(null)
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
// Apartado local creado (board APARTADO, primer paso del pipeline) y encolado
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }),
|
||||
})
|
||||
// La ficha del cliente queda ligada al apartado local
|
||||
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cli-1' },
|
||||
data: { mondayApartadoId: expect.stringMatching(/^loc_/) },
|
||||
})
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({
|
||||
id: 'cli-1',
|
||||
mondayContactoId: null,
|
||||
mondayApartadoId: expect.stringMatching(/^loc_/),
|
||||
})
|
||||
})
|
||||
|
||||
it('con leadMondayId de un lead histórico resuelve la oportunidad vía mapping', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
prismaMock.leadOportunidad.findUnique
|
||||
.mockResolvedValueOnce(null) // no es oportunidad directa
|
||||
.mockResolvedValueOnce({ oportunidadId: 'loc_op_9' }) // mapping lead → oportunidad
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_lead_1' }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'APARTADO' }),
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
expect(prismaMock.leadOportunidad.findUnique).not.toHaveBeenCalled()
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('con apartadoMondayId (botón "Alta Enkontrol" desde Mis apartados) LIGA ese apartado sin crear otro', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearCliente(reqFake({ ...altaBase, apartadoMondayId: 'loc_apt_existente' }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cli-1' },
|
||||
data: { mondayApartadoId: 'loc_apt_existente' },
|
||||
})
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'loc_apt_existente' })
|
||||
})
|
||||
|
||||
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'))
|
||||
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' })
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null) // apartar → NotFoundError
|
||||
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { res, status, json } = resFake()
|
||||
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'lead-9' }), res)
|
||||
|
||||
await crearCliente(reqFake({ ...altaBase, leadMondayId: 'loc_op_9' }), res)
|
||||
|
||||
espia.mockRestore()
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayApartadoId: null })
|
||||
})
|
||||
|
||||
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
|
||||
@@ -187,193 +246,159 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
|
||||
})
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
describe('alta progresiva — local', () => {
|
||||
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')
|
||||
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(oportunidadLocal)
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_9' })
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
|
||||
it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
it('iniciarProgreso crea el alta en progreso (sin réplica externa)', async () => {
|
||||
const { iniciarProgreso } = 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}$/),
|
||||
|
||||
await iniciarProgreso(
|
||||
reqFake({
|
||||
datosGenerales: {
|
||||
nombreCompleto: 'Ana Demo Progreso',
|
||||
curp: 'AEDA900101MBCXXX00',
|
||||
rfc: 'AEDA900101XX0',
|
||||
celular: '6649990003',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
)
|
||||
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' },
|
||||
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
|
||||
expect(prismaMock.cliente.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ nombreCompleto: 'Ana Demo Progreso' }),
|
||||
})
|
||||
})
|
||||
|
||||
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'))
|
||||
it('iniciarProgreso requiere solo datosGenerales.nombreCompleto', async () => {
|
||||
const { iniciarProgreso } = await import('./clientes.controller.js')
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCliente(reqFake({ ...altaConVenta, leadMondayId: 'lead-9' }), res)
|
||||
|
||||
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), 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',
|
||||
}),
|
||||
|
||||
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('guarda capturadoPorId con el sub del JWT (null sin token)', async () => {
|
||||
const { iniciarProgreso } = await import('./clientes.controller.js')
|
||||
const req = reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } })
|
||||
;(req as unknown as { usuarioToken: { sub: string } }).usuarioToken = { sub: 'usr-1' }
|
||||
await iniciarProgreso(req, resFake().res)
|
||||
expect(prismaMock.cliente.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: 'usr-1' }) }),
|
||||
)
|
||||
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), resFake().res)
|
||||
expect(prismaMock.cliente.create).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ capturadoPorId: null }) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('actualizarProgreso guarda el avance del wizard localmente (mondaySync false)', async () => {
|
||||
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||
const datosWizard = { datosGenerales: { nombreCompleto: 'Ana', celular: '6649990003' } }
|
||||
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
|
||||
const { res, json } = resFake()
|
||||
|
||||
await actualizarProgreso({ body: datosWizard, params: { id: 'cli-1' } } as unknown as Request, res)
|
||||
|
||||
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cli-1' },
|
||||
data: { payloadJson: JSON.stringify(datosWizard) },
|
||||
})
|
||||
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
|
||||
})
|
||||
|
||||
it('actualizarProgreso con id desconocido → 404', async () => {
|
||||
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
actualizarProgreso({ body: {}, params: { id: 'no-existe' } } as unknown as Request, resFake().res),
|
||||
).rejects.toThrow('Cliente no encontrado')
|
||||
})
|
||||
|
||||
it('actualizarProgreso rechaza un body que no sea objeto', async () => {
|
||||
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
|
||||
|
||||
await expect(
|
||||
actualizarProgreso({ body: 'no-objeto', params: { id: 'cli-1' } } as unknown as Request, resFake().res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('completarProgreso cierra el alta y mueve la oportunidad del funnel a Aparto', async () => {
|
||||
const { completarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue({
|
||||
id: 'cli-1',
|
||||
capturadoPorId: 'usr-9',
|
||||
mondayApartadoId: null,
|
||||
})
|
||||
const altaCompleta = {
|
||||
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
|
||||
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
|
||||
leadMondayId: 'loc_op_9',
|
||||
}
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cli-1' },
|
||||
data: expect.objectContaining({ proyecto: 'SENDA', unidadCodigo: 'U-999' }),
|
||||
})
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado' }),
|
||||
})
|
||||
// La ficha del apartado local se enriquece con lo capturado en el alta
|
||||
const enriquecimiento = prismaMock.leadCaptura.update.mock.calls.find(
|
||||
(c) => (c[0] as { where: { leadId: string } }).where.leadId !== 'loc_op_9',
|
||||
)
|
||||
expect(enriquecimiento).toBeDefined()
|
||||
const datos = JSON.parse(
|
||||
(enriquecimiento![0] as { data: { datosJson: string } }).data.datosJson,
|
||||
) as Record<string, unknown>
|
||||
expect(datos).toMatchObject({ unidadCodigo: 'U-999', precioFinal: 1500000, enganche: 20000, estadoLabel: 'Pago Apartado' })
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({
|
||||
id: 'cli-1',
|
||||
mondayContactoId: null,
|
||||
mondayApartadoId: expect.stringMatching(/^loc_/),
|
||||
})
|
||||
})
|
||||
|
||||
it('completarProgreso rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
|
||||
const { completarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1' })
|
||||
|
||||
await expect(
|
||||
completarProgreso(
|
||||
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
|
||||
resFake().res,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('completarProgreso 404 si el progreso no existe', async () => {
|
||||
const { completarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
completarProgreso(reqFake(altaBase, { id: 'no-existe' }), resFake().res),
|
||||
).rejects.toThrow('Cliente no encontrado')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user