feat(alta): borrador, pre-llenado desde cotización y liga remota de firma
- Borradores por vendedor (guardar/continuar/eliminar), se limpia al registrar - Pre-llenado con ?cotizacion=<id> (cliente, contacto, proyecto, montos); botón 'Iniciar alta' en Mis cotizaciones del cotizador - Liga remota pública /client/firma/<token> (JWT scope firma-remota, 72h, rate limit, solo datos mínimos sin PLD): firma digital + subida de documentos que prende checkboxes en Monday Apartado - Tokens de liga no sirven como sesión y viceversa - Tests: api 228, alta 82, cotizador 82
This commit is contained in:
249
api-gateway/src/controllers/liga.controller.test.ts
Normal file
249
api-gateway/src/controllers/liga.controller.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import {
|
||||
firmarRemoto,
|
||||
generarLiga,
|
||||
generarLigaToken,
|
||||
obtenerClientePublico,
|
||||
subirDocumentosPublico,
|
||||
verificarLigaToken,
|
||||
} from './liga.controller.js'
|
||||
|
||||
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
cliente: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
documento: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
cambiarValorColumna: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||
|
||||
const SECRET = 'test-jwt-secret' // JWT_SECRET fijado en vitest.config.ts
|
||||
|
||||
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||
return { params: {}, body: {}, ip: '127.0.0.1', ...overrides } as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { json, status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
const clienteFake = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'c-1',
|
||||
nombreCompleto: 'CLIENTE DEMO',
|
||||
curp: 'CURPSECRETA123',
|
||||
rfc: 'RFCSECRETO1',
|
||||
email: 'demo@example.com',
|
||||
celular: '5551234567',
|
||||
proyecto: 'DIAGONAL',
|
||||
unidadCodigo: 'U-101',
|
||||
payloadJson: '{"datosGenerales":{"direccion":"CALLE SECRETA 1"}}',
|
||||
firmaPng: null,
|
||||
mondayContactoId: null,
|
||||
mondayApartadoId: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('token de liga (scope firma-remota)', () => {
|
||||
it('ida y vuelta: scope, clienteId y expiración de 72h por defecto', () => {
|
||||
const token = generarLigaToken('c-1')
|
||||
const payload = verificarLigaToken(token)
|
||||
expect(payload.scope).toBe('firma-remota')
|
||||
expect(payload.clienteId).toBe('c-1')
|
||||
|
||||
const { exp, iat } = jwt.decode(token) as { exp: number; iat: number }
|
||||
expect(exp - iat).toBe(72 * 3600)
|
||||
})
|
||||
|
||||
it('rechaza un token de login (sin scope firma-remota)', () => {
|
||||
const tokenLogin = jwt.sign({ sub: 'u-1', username: 'asesor', rol: 'vendedor' }, SECRET)
|
||||
expect(() => verificarLigaToken(tokenLogin)).toThrow('Liga inválida o expirada')
|
||||
})
|
||||
|
||||
it('rechaza un token expirado', () => {
|
||||
const expirado = jwt.sign({ scope: 'firma-remota', clienteId: 'c-1' }, SECRET, { expiresIn: '-1s' })
|
||||
expect(() => verificarLigaToken(expirado)).toThrow('Liga inválida o expirada')
|
||||
})
|
||||
|
||||
it('rechaza basura y tokens de liga sin clienteId', () => {
|
||||
expect(() => verificarLigaToken('no-es-un-jwt')).toThrow('Liga inválida o expirada')
|
||||
const sinCliente = jwt.sign({ scope: 'firma-remota' }, SECRET)
|
||||
expect(() => verificarLigaToken(sinCliente)).toThrow('Liga inválida o expirada')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generarLiga (POST /clientes/:id/liga)', () => {
|
||||
it('devuelve token, url pública y expiración', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await generarLiga(reqFake({ params: { id: 'c-1' } }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
const cuerpo = json.mock.calls[0]![0]
|
||||
expect(cuerpo.url).toBe(`/client/firma/${cuerpo.token}`)
|
||||
expect(verificarLigaToken(cuerpo.token).clienteId).toBe('c-1')
|
||||
expect(new Date(cuerpo.expiraEn).getTime()).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('404 si el cliente no existe', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(null)
|
||||
await expect(generarLiga(reqFake({ params: { id: 'x' } }), resFake().res)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('obtenerClientePublico (GET /clientes/publico/:token)', () => {
|
||||
it('expone SOLO nombre, proyecto, unidad y estado de firma/documentos (nada de PLD)', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
prismaMock.documento.findMany.mockResolvedValue([
|
||||
{ tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: new Date() },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await obtenerClientePublico(reqFake({ params: { token } }), res)
|
||||
|
||||
const cuerpo = json.mock.calls[0]![0]
|
||||
expect(cuerpo).toEqual({
|
||||
nombre: 'CLIENTE DEMO',
|
||||
proyecto: 'DIAGONAL',
|
||||
unidad: 'U-101',
|
||||
firma: false,
|
||||
documentos: [{ tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: expect.any(Date) }],
|
||||
})
|
||||
// Guardas explícitas anti-filtrado PLD
|
||||
const crudo = JSON.stringify(cuerpo)
|
||||
expect(crudo).not.toContain('CURPSECRETA123')
|
||||
expect(crudo).not.toContain('RFCSECRETO1')
|
||||
expect(crudo).not.toContain('CALLE SECRETA')
|
||||
expect(cuerpo).not.toHaveProperty('payloadJson')
|
||||
expect(cuerpo).not.toHaveProperty('curp')
|
||||
expect(cuerpo).not.toHaveProperty('rfc')
|
||||
expect(cuerpo).not.toHaveProperty('email')
|
||||
expect(cuerpo).not.toHaveProperty('celular')
|
||||
})
|
||||
|
||||
it('no acepta un token de login aunque sea JWT válido (401)', async () => {
|
||||
const tokenLogin = jwt.sign({ sub: 'u-1', username: 'asesor', rol: 'vendedor' }, SECRET)
|
||||
await expect(
|
||||
obtenerClientePublico(reqFake({ params: { token: tokenLogin } }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 401 })
|
||||
expect(prismaMock.cliente.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('firmarRemoto (POST /clientes/publico/:token/firma)', () => {
|
||||
const firmaPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA='
|
||||
|
||||
it('guarda la firma en Cliente.firmaPng', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
prismaMock.cliente.update.mockResolvedValue(clienteFake({ firmaPng }))
|
||||
const { res, json } = resFake()
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await firmarRemoto(reqFake({ params: { token }, body: { firmaDataUrl: firmaPng } }), res)
|
||||
|
||||
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||
where: { id: 'c-1' },
|
||||
data: { firmaPng },
|
||||
})
|
||||
expect(json).toHaveBeenCalledWith({ ok: true, firma: true })
|
||||
})
|
||||
|
||||
it('rechaza una firma que no sea PNG data URL', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await expect(
|
||||
firmarRemoto(reqFake({ params: { token }, body: { firmaDataUrl: 'data:text/html;base64,PHNjcmlwdA==' } }), resFake().res),
|
||||
).rejects.toThrow()
|
||||
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', () => {
|
||||
const archivoFake = {
|
||||
originalname: 'curp.pdf',
|
||||
path: '/tmp/uploads/c-1/CURP_abc.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
size: 1234,
|
||||
} as Express.Multer.File
|
||||
|
||||
it('rechaza tipos fuera de la whitelist (400)', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await expect(
|
||||
subirDocumentosPublico(
|
||||
reqFake({ params: { token }, body: { tipo: 'CONTRATO' }, files: [archivoFake] }),
|
||||
resFake().res,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 })
|
||||
expect(prismaMock.documento.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acepta un tipo de la whitelist y persiste el documento (201)', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
|
||||
const { res, status } = resFake()
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await subirDocumentosPublico(
|
||||
reqFake({ params: { token }, body: { tipo: 'curp' }, files: [archivoFake] }),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(prismaMock.documento.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ clienteId: 'c-1', tipo: 'CURP', mime: 'application/pdf' }),
|
||||
})
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
})
|
||||
|
||||
it('prende el checkbox en Apartado cuando el cliente tiene mondayApartadoId', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake({ mondayApartadoId: 'ap-9' }))
|
||||
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
|
||||
mondayMock.cambiarValorColumna.mockResolvedValue(undefined)
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await subirDocumentosPublico(
|
||||
reqFake({ params: { token }, body: { tipo: 'CURP' }, files: [archivoFake] }),
|
||||
resFake().res,
|
||||
)
|
||||
|
||||
expect(mondayMock.cambiarValorColumna).toHaveBeenCalledWith(expect.any(String), 'ap-9', 'CURP', true)
|
||||
})
|
||||
|
||||
it('no llama a Monday sin mondayApartadoId', async () => {
|
||||
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
|
||||
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
|
||||
const token = generarLigaToken('c-1')
|
||||
|
||||
await subirDocumentosPublico(
|
||||
reqFake({ params: { token }, body: { tipo: 'INE' }, files: [archivoFake] }),
|
||||
resFake().res,
|
||||
)
|
||||
|
||||
expect(mondayMock.cambiarValorColumna).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user