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
176 lines
7.0 KiB
TypeScript
176 lines
7.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import bcrypt from 'bcryptjs'
|
|
import type { Usuario } from '@prisma/client'
|
|
import { cambiarPassword, login, me, normalizarUsername, validarPasswordNueva, verificarToken } from './auth.service.js'
|
|
|
|
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
|
|
|
|
const prismaMock = vi.hoisted(() => ({
|
|
usuario: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
|
|
|
function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
|
|
return {
|
|
id: 'u-1',
|
|
username: 'erick israel pena ramos',
|
|
nombre: 'ERICK ISRAEL PEÑA RAMOS',
|
|
passwordHash: bcrypt.hashSync('Frasa2026', 12),
|
|
rol: 'vendedor',
|
|
asignacion: 'MONTEBELLO',
|
|
empleadoId: 13676,
|
|
mondayUserId: null,
|
|
activo: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('normalizarUsername', () => {
|
|
it('minúsculas, sin acentos ni ñ, espacios simples', () => {
|
|
expect(normalizarUsername(' ERICK ISRAEL PEÑA RAMOS ')).toBe('erick israel pena ramos')
|
|
expect(normalizarUsername('Ma. Cristina Barrera López')).toBe('ma. cristina barrera lopez')
|
|
})
|
|
})
|
|
|
|
describe('login', () => {
|
|
it('devuelve token y usuario público con credenciales correctas', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
|
|
|
const resultado = await login('erick israel pena ramos', 'Frasa2026')
|
|
|
|
expect(resultado.token).toBeTruthy()
|
|
expect(resultado.usuario).toEqual({
|
|
nombre: 'ERICK ISRAEL PEÑA RAMOS',
|
|
rol: 'vendedor',
|
|
asignacion: 'MONTEBELLO',
|
|
mondayUserId: null,
|
|
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
|
acciones: [],
|
|
})
|
|
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
|
})
|
|
|
|
it('es case-insensitive y tolerante a espacios extra y acentos', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
|
|
|
const resultado = await login(' ERICK Israel PEÑA ramos ', 'Frasa2026')
|
|
|
|
expect(resultado.token).toBeTruthy()
|
|
expect(prismaMock.usuario.findUnique).toHaveBeenCalledWith({
|
|
where: { username: 'erick israel pena ramos' },
|
|
})
|
|
})
|
|
|
|
it('rechaza password incorrecto con error genérico', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
|
await expect(login('erick israel pena ramos', 'otra')).rejects.toThrow('Credenciales inválidas')
|
|
})
|
|
|
|
it('rechaza usuario inexistente con el mismo error genérico', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
|
await expect(login('nadie', 'Frasa2026')).rejects.toThrow('Credenciales inválidas')
|
|
})
|
|
|
|
it('rechaza usuario inactivo con el mismo error genérico', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
|
|
await expect(login('erick israel pena ramos', 'Frasa2026')).rejects.toThrow('Credenciales inválidas')
|
|
})
|
|
})
|
|
|
|
describe('verificarToken', () => {
|
|
it('lanza 401 con token basura', () => {
|
|
expect(() => verificarToken('no-es-un-jwt')).toThrow('Token inválido o expirado')
|
|
})
|
|
})
|
|
|
|
describe('me', () => {
|
|
it('devuelve el usuario público del token', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
|
const resultado = await me({ sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' })
|
|
expect(resultado.nombre).toBe('ERICK ISRAEL PEÑA RAMOS')
|
|
})
|
|
|
|
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
|
|
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
|
|
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'])
|
|
})
|
|
|
|
it('rechaza si el usuario ya no existe o está inactivo', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
|
await expect(me({ sub: 'x', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
|
|
await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas')
|
|
})
|
|
})
|
|
|
|
describe('validarPasswordNueva', () => {
|
|
it('exige al menos 8 caracteres y un número', () => {
|
|
expect(validarPasswordNueva('corta1')).toContain('8 caracteres')
|
|
expect(validarPasswordNueva('sinnumeros')).toContain('número')
|
|
expect(validarPasswordNueva('')).toBeTruthy()
|
|
})
|
|
|
|
it('acepta passwords que cumplen la regla', () => {
|
|
expect(validarPasswordNueva('Frasa2026')).toBeNull()
|
|
expect(validarPasswordNueva('12345678')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('cambiarPassword', () => {
|
|
const payload = { sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' }
|
|
|
|
it('con actual correcta re-hashea y guarda la nueva (cost 12)', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
|
prismaMock.usuario.update.mockResolvedValue(usuarioFake())
|
|
|
|
await cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')
|
|
|
|
expect(prismaMock.usuario.update).toHaveBeenCalledTimes(1)
|
|
const llamada = prismaMock.usuario.update.mock.calls[0]?.[0] as {
|
|
where: { id: string }
|
|
data: { passwordHash: string }
|
|
}
|
|
expect(llamada.where).toEqual({ id: 'u-1' })
|
|
expect(llamada.data.passwordHash).not.toBe('NuevaPass2026')
|
|
expect(await bcrypt.compare('NuevaPass2026', llamada.data.passwordHash)).toBe(true)
|
|
// Cost 12: el hash bcrypt lleva el factor en el propio string ($2b$12$…)
|
|
expect(llamada.data.passwordHash).toMatch(/^\$2[aby]\$12\$/)
|
|
})
|
|
|
|
it('rechaza una nueva que no cumple la política sin tocar la BD', async () => {
|
|
await expect(cambiarPassword(payload, 'Frasa2026', 'corta')).rejects.toThrow('8 caracteres')
|
|
expect(prismaMock.usuario.findUnique).not.toHaveBeenCalled()
|
|
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('con actual incorrecta responde 400 y no actualiza', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake())
|
|
|
|
const error = await cambiarPassword(payload, 'mala', 'NuevaPass2026').catch((e: unknown) => e)
|
|
expect(error).toMatchObject({ status: 400, message: 'La contraseña actual no es correcta' })
|
|
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('usuario inexistente o inactivo: 401 genérico', async () => {
|
|
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
|
await expect(cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')).rejects.toThrow(
|
|
'Credenciales inválidas',
|
|
)
|
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false }))
|
|
await expect(cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')).rejects.toThrow(
|
|
'Credenciales inválidas',
|
|
)
|
|
})
|
|
})
|