diff --git a/tests/services/auth.service.test.ts b/tests/services/auth.service.test.ts new file mode 100644 index 0000000..8947a29 --- /dev/null +++ b/tests/services/auth.service.test.ts @@ -0,0 +1,304 @@ +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 tokens when credentials are valid', async () => { + const mockUser = { + id: 'user-1', + email: 'test@example.com', + 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.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(); + }); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index b411efb..651fb47 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -10,10 +10,52 @@ export type MockDb = { delete: ReturnType['delete']>; }; +/** Build a select chain that resolves to the given rows at .limit(n) */ +export function selectChain(resolveAtLimit: unknown[] = []) { + const limitFn = vi.fn().mockResolvedValue(resolveAtLimit); + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: limitFn, + orderBy: vi.fn().mockReturnValue({ limit: limitFn }), + }), + limit: limitFn, + }), + }; +} + +/** Build an insert chain that resolves at .returning() or .values() */ +export function insertChain(resolveAtReturning: unknown[] = []) { + const returningFn = vi.fn().mockResolvedValue(resolveAtReturning); + const chainFromValues = { + returning: returningFn, + then: (resolve: (v?: unknown) => void) => resolve(undefined), + }; + return { + values: vi.fn().mockReturnValue(chainFromValues), + returning: returningFn, + }; +} + +/** Build an update chain that resolves at .where() */ +export function updateChain(resolveAtWhere: unknown[] = []) { + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(resolveAtWhere), + }), + }; +} + +/** Build a delete chain that resolves at .where() */ +export function deleteChain() { + return { + where: vi.fn().mockResolvedValue(undefined), + }; +} + /** * Create a chainable mock for Drizzle DB operations. - * Configure chain terminals (limit, returning) to resolve to desired values. - * Example: mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([user]) }) }) }) + * Use mockReturnValue with selectChain/insertChain/updateChain/deleteChain. */ export function createMockDb(): MockDb { const chain = {