- 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
250 lines
8.8 KiB
TypeScript
250 lines
8.8 KiB
TypeScript
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()
|
|
})
|
|
})
|