import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('bcryptjs', () => ({ default: { compare: vi.fn(), hash: vi.fn(), }, })) vi.mock('../../services/notificationService', () => ({ sendTransactionalEmail: vi.fn() })) vi.mock('./auth.employee.repo', () => ({ findEmployeeWithCompanyById: vi.fn(), findEmployeeWithCompanyByEmail: vi.fn(), findActiveEmployeeByEmail: vi.fn(), findEmployeeByResetToken: vi.fn(), findEmployeeById: vi.fn(), updatePreferredLanguage: vi.fn(), resetPassword: vi.fn(), })) import bcrypt from 'bcryptjs' import { AppError } from '../../http/errors' import { sendTransactionalEmail } from '../../services/notificationService' import * as repo from './auth.employee.repo' import * as service from './auth.employee.service' const employee = { id: 'employee_1', email: 'agent@example.test', firstName: 'Aya', lastName: 'Agent', role: 'AGENT', preferredLanguage: 'fr', companyId: 'company_1', isActive: true, emailVerified: true, emailVerificationToken: null, passwordHash: 'hash_old', company: { name: 'Atlas Cars', slug: 'atlas' }, } describe('auth.employee.service edge behavior', () => { beforeEach(() => { vi.clearAllMocks() process.env.JWT_SECRET = 'test-secret' process.env.JWT_EXPIRY = '8h' delete process.env.DASHBOARD_URL delete process.env.NEXT_PUBLIC_DASHBOARD_URL vi.mocked(bcrypt.compare).mockResolvedValue(true as never) vi.mocked(bcrypt.hash).mockResolvedValue('hash_new' as never) }) it('rejects inactive employee sessions before presenting tenant data', async () => { vi.mocked(repo.findEmployeeWithCompanyById).mockResolvedValue({ ...employee, isActive: false } as never) await expect(service.getMe('employee_1')).rejects.toMatchObject({ statusCode: 401, error: 'unauthenticated', }) }) it('logs in active employees with a signed token and company context', async () => { vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue(employee as never) const result = await service.login({ email: 'agent@example.test', password: 'correct-password' }) expect(bcrypt.compare).toHaveBeenCalledWith('correct-password', 'hash_old') expect(result).toMatchObject({ token: expect.any(String), employee: { id: 'employee_1', companyId: 'company_1', companyName: 'Atlas Cars', companySlug: 'atlas', }, }) }) it('rejects login for employees with a pending email verification token', async () => { vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, emailVerified: null, emailVerificationToken: 'verify-token', } as never) await expect(service.login({ email: 'agent@example.test', password: 'correct-password' })).rejects.toMatchObject({ statusCode: 401, error: 'email_not_verified', }) }) it('logs in legacy full-signup owners that were created without a verification token', async () => { vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, role: 'OWNER', emailVerified: null, emailVerificationToken: null, } as never) const result = await service.login({ email: 'agent@example.test', password: 'correct-password' }) expect(result).toMatchObject({ token: expect.any(String), employee: { id: 'employee_1', role: 'OWNER', }, }) }) it('distinguishes employees without passwords from wrong credentials', async () => { vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never) await expect(service.login({ email: 'agent@example.test', password: 'anything' })).rejects.toMatchObject({ statusCode: 401, error: 'password_not_set', }) expect(bcrypt.compare).not.toHaveBeenCalled() }) it('sends reset links to active employees using the dashboard base path exactly once', async () => { process.env.DASHBOARD_URL = 'https://tenant.example.test/app' vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(employee as never) vi.mocked(sendTransactionalEmail).mockResolvedValue({ success: true } as never) await expect(service.forgotPassword('agent@example.test')).resolves.toEqual({ message: 'If that email is registered, a reset link has been sent.', }) expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'agent@example.test', subject: expect.any(String), html: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='), text: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='), })) }) it('does not disclose whether forgot-password emails exist', async () => { vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(null) await expect(service.forgotPassword('missing@example.test')).resolves.toEqual({ message: 'If that email is registered, a reset link has been sent.', }) expect(sendTransactionalEmail).not.toHaveBeenCalled() }) it('updates employee password from a stored reset token and clears reset state through the repo', async () => { vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(employee as never) await expect(service.resetPassword('stored-token', 'new-secure-password')).resolves.toEqual({ message: 'Password updated successfully. You can now sign in.', }) expect(bcrypt.hash).toHaveBeenCalledWith('new-secure-password', 12) expect(repo.resetPassword).toHaveBeenCalledWith('employee_1', 'hash_new') }) it('rejects invalid reset tokens before hashing new passwords', async () => { vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null) vi.mocked(repo.findEmployeeById).mockResolvedValue(null) await expect(service.resetPassword('bad-token', 'new-secure-password')).rejects.toMatchObject({ statusCode: 400, error: 'invalid_token', }) expect(bcrypt.hash).not.toHaveBeenCalled() }) it('delegates language preference updates and returns a stable contract', async () => { vi.mocked(repo.updatePreferredLanguage).mockResolvedValue({} as never) await expect(service.updateLanguage('employee_1', 'ar')).resolves.toEqual({ language: 'ar' }) expect(repo.updatePreferredLanguage).toHaveBeenCalledWith('employee_1', 'ar') }) })