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 { 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', ) }) })