import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AuthService } from '../../src/services/auth/auth.service.js'; import { createMockDb, selectChain, insertChain, updateChain, deleteChain, } from '../test-utils.js'; vi.mock('../../src/utils/password.js', () => ({ hashPassword: vi.fn().mockResolvedValue('hashed-password'), verifyPassword: vi.fn().mockResolvedValue(true), })); vi.mock('../../src/utils/jwt.js', () => ({ signAccessToken: vi.fn().mockResolvedValue('access-token'), signRefreshToken: vi.fn().mockResolvedValue('refresh-token'), verifyToken: vi.fn(), isRefreshPayload: vi.fn(), hashToken: vi.fn((t: string) => `hash-${t}`), })); vi.mock('node:crypto', () => ({ randomBytes: vi.fn(() => ({ toString: () => 'abc123', })), randomUUID: vi.fn(() => 'uuid-session-1'), })); import { hashPassword, verifyPassword } from '../../src/utils/password.js'; import { signAccessToken, signRefreshToken, verifyToken, isRefreshPayload } from '../../src/utils/jwt.js'; describe('AuthService', () => { let mockDb: ReturnType; let authService: AuthService; beforeEach(() => { vi.clearAllMocks(); mockDb = createMockDb(); authService = new AuthService(mockDb as never); }); describe('register', () => { it('registers a new user when email and nickname are available', async () => { (mockDb.select as ReturnType) .mockReturnValueOnce(selectChain([])) .mockReturnValueOnce(selectChain([])); (mockDb.insert as ReturnType) .mockReturnValueOnce(insertChain([{ id: 'user-123' }])) .mockReturnValueOnce(insertChain([])); const result = await authService.register({ email: 'test@example.com', password: 'password123', nickname: 'tester', }); expect(result.userId).toBe('user-123'); expect(result.verificationCode).toBeDefined(); expect(hashPassword).toHaveBeenCalledWith('password123'); }); it('throws when email is already taken', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([{ id: 'existing', email: 'test@example.com' }]) ); await expect( authService.register({ email: 'test@example.com', password: 'password123', nickname: 'tester', }) ).rejects.toMatchObject({ code: 'EMAIL_TAKEN' }); }); it('throws when nickname is already taken', async () => { (mockDb.select as ReturnType) .mockReturnValueOnce(selectChain([])) .mockReturnValueOnce(selectChain([{ id: 'existing', nickname: 'tester' }])); await expect( authService.register({ email: 'test@example.com', password: 'password123', nickname: 'tester', }) ).rejects.toMatchObject({ code: 'NICKNAME_TAKEN' }); }); }); describe('login', () => { it('returns user and tokens when credentials are valid', async () => { const mockUser = { id: 'user-1', email: 'test@example.com', nickname: 'tester', avatarUrl: null, role: 'free', emailVerifiedAt: null, passwordHash: 'hashed', }; (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([mockUser]) ); (mockDb.insert as ReturnType).mockReturnValueOnce( insertChain([]) ); const result = await authService.login({ email: 'test@example.com', password: 'password123', }); expect(result.user).toBeDefined(); expect(result.user.id).toBe('user-1'); expect(result.user.email).toBe('test@example.com'); expect(result.accessToken).toBe('access-token'); expect(result.refreshToken).toBe('refresh-token'); expect(verifyPassword).toHaveBeenCalledWith('hashed', 'password123'); expect(signAccessToken).toHaveBeenCalled(); expect(signRefreshToken).toHaveBeenCalled(); }); it('throws when user not found', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([]) ); await expect( authService.login({ email: 'nonexistent@example.com', password: 'x' }) ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); it('throws when password is wrong', async () => { vi.mocked(verifyPassword).mockResolvedValueOnce(false); (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([{ id: 'user-1', email: 'test@example.com', passwordHash: 'hashed' }]) ); await expect( authService.login({ email: 'test@example.com', password: 'wrong' }) ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); }); describe('logout', () => { it('deletes session by refresh token hash', async () => { (mockDb.delete as ReturnType).mockReturnValueOnce( deleteChain() ); await authService.logout('some-refresh-token'); expect(mockDb.delete).toHaveBeenCalled(); }); }); describe('refresh', () => { it('returns new tokens when refresh token is valid', async () => { vi.mocked(verifyToken).mockResolvedValueOnce({ sub: 'user-1', sid: 'sid-1', type: 'refresh', } as never); vi.mocked(isRefreshPayload).mockReturnValueOnce(true); (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([{ id: 'sess-1', userId: 'user-1' }]) ); (mockDb.delete as ReturnType).mockReturnValueOnce( deleteChain() ); (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([{ id: 'user-1', email: 'test@example.com' }]) ); (mockDb.insert as ReturnType).mockReturnValueOnce( insertChain([]) ); const result = await authService.refresh({ refreshToken: 'valid-refresh-token', }); expect(result.accessToken).toBe('access-token'); expect(result.refreshToken).toBe('refresh-token'); }); it('throws when token is not a refresh payload', async () => { vi.mocked(verifyToken).mockResolvedValueOnce({ sub: 'user-1', type: 'access', } as never); vi.mocked(isRefreshPayload).mockReturnValueOnce(false); await expect( authService.refresh({ refreshToken: 'access-token' }) ).rejects.toMatchObject({ message: expect.stringContaining('Invalid refresh token') }); }); it('throws when session not found', async () => { vi.mocked(verifyToken).mockResolvedValueOnce({ sub: 'user-1', sid: 'sid-1', type: 'refresh', } as never); vi.mocked(isRefreshPayload).mockReturnValueOnce(true); (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([]) ); await expect( authService.refresh({ refreshToken: 'invalid-or-expired' }) ).rejects.toMatchObject({ code: 'INVALID_REFRESH_TOKEN' }); }); }); describe('verifyEmail', () => { it('throws when verification code is invalid', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([]) ); await expect( authService.verifyEmail('user-1', 'WRONG') ).rejects.toMatchObject({ code: 'INVALID_CODE' }); }); it('updates user and deletes code when valid', async () => { const mockCode = { id: 'code-1', userId: 'user-1', code: 'ABC123', expiresAt: new Date(Date.now() + 60000), }; (mockDb.select as ReturnType) .mockReturnValueOnce(selectChain([mockCode])) .mockReturnValueOnce(selectChain([{ id: 'user-1', emailVerifiedAt: null }])); (mockDb.update as ReturnType).mockReturnValueOnce( updateChain([{ id: 'user-1' }]) ); (mockDb.delete as ReturnType).mockReturnValueOnce( deleteChain() ); await authService.verifyEmail('user-1', 'ABC123'); expect(mockDb.update).toHaveBeenCalled(); expect(mockDb.delete).toHaveBeenCalled(); }); }); describe('forgotPassword', () => { it('returns empty token when user not found', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([]) ); const result = await authService.forgotPassword('unknown@example.com'); expect(result.token).toBe(''); expect(result.expiresAt).toBeInstanceOf(Date); }); it('returns token when user exists', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([{ id: 'user-1', email: 'test@example.com' }]) ); (mockDb.insert as ReturnType).mockReturnValueOnce( insertChain([]) ); const result = await authService.forgotPassword('test@example.com'); expect(result.token).toBeDefined(); expect(result.token).not.toBe(''); expect(result.expiresAt).toBeInstanceOf(Date); }); }); describe('resetPassword', () => { it('throws when token is invalid', async () => { (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([]) ); await expect( authService.resetPassword('invalid-token', 'newPassword123') ).rejects.toMatchObject({ code: 'INVALID_RESET_TOKEN' }); }); it('updates password when token is valid', async () => { const mockRecord = { id: 'rec-1', userId: 'user-1', expiresAt: new Date(Date.now() + 60000) }; (mockDb.select as ReturnType).mockReturnValueOnce( selectChain([mockRecord]) ); (mockDb.update as ReturnType).mockReturnValueOnce( updateChain([{ id: 'user-1' }]) ); (mockDb.delete as ReturnType).mockReturnValueOnce( deleteChain() ); await authService.resetPassword('valid-token', 'newPassword123'); expect(hashPassword).toHaveBeenCalledWith('newPassword123'); expect(mockDb.update).toHaveBeenCalled(); expect(mockDb.delete).toHaveBeenCalled(); }); }); });