import { UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { AuthService } from './auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; describe('AuthService', () => { let authService: AuthService; let usersService: { findByEmail: jest.Mock; findById: jest.Mock }; let prisma: { refreshToken: { create: jest.Mock; findUnique: jest.Mock; update: jest.Mock; updateMany: jest.Mock; }; }; let jwtService: { sign: jest.Mock }; const activeUser = { id: 'user-1', email: 'user@example.com', role: 'STAFF', isActive: true, passwordHash: '', }; beforeEach(async () => { activeUser.passwordHash = await bcrypt.hash('correct-password', 4); usersService = { findByEmail: jest.fn(), findById: jest.fn() }; prisma = { refreshToken: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn(), }, }; jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; authService = new AuthService( usersService as unknown as UsersService, prisma as unknown as PrismaService, jwtService as never, ); }); describe('login', () => { it('throws when the user does not exist', async () => { usersService.findByEmail.mockResolvedValue(null); await expect( authService.login('missing@example.com', 'x'), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('throws when the user is deactivated', async () => { usersService.findByEmail.mockResolvedValue({ ...activeUser, isActive: false, }); await expect( authService.login(activeUser.email, 'correct-password'), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('throws when the password does not match', async () => { usersService.findByEmail.mockResolvedValue(activeUser); await expect( authService.login(activeUser.email, 'wrong-password'), ).rejects.toBeInstanceOf(UnauthorizedException); }); it('returns tokens and stores a hashed refresh token on success', async () => { usersService.findByEmail.mockResolvedValue(activeUser); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.login( activeUser.email, 'correct-password', ); expect(result.accessToken).toBe('signed.jwt.token'); expect(result.refreshToken).toEqual(expect.any(String)); const [[createArg]] = prisma.refreshToken.create.mock.calls as [ [{ data: { userId: string } }], ]; expect(createArg.data.userId).toBe(activeUser.id); }); }); describe('refresh', () => { it('throws when the refresh token is unknown', async () => { prisma.refreshToken.findUnique.mockResolvedValue(null); await expect(authService.refresh('unknown-token')).rejects.toBeInstanceOf( UnauthorizedException, ); }); it('throws when the refresh token is revoked', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: new Date(), expiresAt: new Date(Date.now() + 1000), }); await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf( UnauthorizedException, ); }); it('throws when the refresh token is expired', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: null, expiresAt: new Date(Date.now() - 1000), }); await expect(authService.refresh('expired-token')).rejects.toBeInstanceOf( UnauthorizedException, ); }); it('rotates the refresh token on success', async () => { prisma.refreshToken.findUnique.mockResolvedValue({ id: 'rt-1', userId: activeUser.id, revokedAt: null, expiresAt: new Date(Date.now() + 1000), }); usersService.findById.mockResolvedValue(activeUser); prisma.refreshToken.update.mockResolvedValue({}); prisma.refreshToken.create.mockResolvedValue({}); const result = await authService.refresh('valid-token'); expect(result.accessToken).toBe('signed.jwt.token'); expect(prisma.refreshToken.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'rt-1' } }), ); }); }); describe('logout', () => { it('revokes the matching refresh token', async () => { prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); await authService.logout('some-token'); expect(prisma.refreshToken.updateMany).toHaveBeenCalled(); }); }); });