Files
FRASA-CAS/api-gateway/src/controllers/clientes.controller.test.ts
Consult AS Technology c8357fe582 feat(7-mejoras): notas persisten + soporte + captura visible
1. Notas propagan entre etapas (convertirLead/apartar updateMany)
2. Afluencia: conexión → aviso amarillo
3. Escrituración: click nombre abre bitácora
4. Co-acreditado: opción 'no tiene'
5. Nota del alta de lead → bitácora
6. Módulo Soporte: formulario + listado solicitudes
7. Captura visible para todos
2026-09-11 23:17:25 +00:00

408 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
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(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
recordatorioVendedor: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
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')
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('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('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('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()
})
})
// ---------- Alta local (POST /api/clientes) ----------
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.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('201: persiste el Cliente en SQLite y responde mondayContactoId null', async () => {
const { crearCliente } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await crearCliente(reqFake(altaBase), res)
expect(status).toHaveBeenCalledWith(201)
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(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')
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: 'loc_op_9' }), res)
espia.mockRestore()
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ id: 'cli-1', mondayApartadoId: null })
})
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('alta progresiva — local', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
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('iniciarProgreso crea el alta en progreso (sin réplica externa)', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await iniciarProgreso(
reqFake({
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
celular: '6649990003',
},
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
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('iniciarProgreso requiere solo datosGenerales.nombreCompleto', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
expect(status).toHaveBeenCalledWith(201)
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')
})
})