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 { 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 = {}) => ({ 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() }) })