diff --git a/packages/server/test/auth/auth.service.spec.ts b/packages/server/test/auth/auth.service.spec.ts index e69de29b..43306fd5 100644 --- a/packages/server/test/auth/auth.service.spec.ts +++ b/packages/server/test/auth/auth.service.spec.ts @@ -0,0 +1,341 @@ +// test/auth/auth.service.spec.ts + +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from '../../src/auth/auth.service'; +import { UserRepository } from '../../src/auth/user.repository'; +import { AccountRepository } from '../../src/account/account.repository'; +import { JwtService } from '@nestjs/jwt'; +import { AuthRedisRepository } from '../../src/redis/auth-redis.repository'; +import { UnauthorizedException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { User } from '../../src/auth/user.entity'; +import { SignUpDto } from '../../src/auth/dtos/sign-up.dto'; +import { jwtConstants, ACCESS_TOKEN_TTL, REFRESH_TOKEN_TTL, DEFAULT_KRW, DEFAULT_USDT, DEFAULT_BTC, GUEST_ID_TTL } from '../../src/auth/constants'; +import { v4 as uuidv4 } from 'uuid'; + +// UUID 모킹 +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid'), +})); + +describe('AuthService', () => { + let authService: AuthService; + let userRepository: jest.Mocked; + let accountRepository: jest.Mocked; + let jwtService: jest.Mocked; + let authRedisRepository: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: UserRepository, + useValue: { + findOne: jest.fn(), + findOneBy: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: AccountRepository, + useValue: { + save: jest.fn(), + }, + }, + { + provide: JwtService, + useValue: { + signAsync: jest.fn(), + verifyAsync: jest.fn(), + }, + }, + { + provide: AuthRedisRepository, + useValue: { + setAuthData: jest.fn(), + getAuthData: jest.fn(), + deleteAuthData: jest.fn(), + }, + }, + ], + }).compile(); + + authService = module.get(AuthService); + userRepository = module.get(UserRepository) as jest.Mocked; + accountRepository = module.get(AccountRepository) as jest.Mocked; + jwtService = module.get(JwtService) as jest.Mocked; + authRedisRepository = module.get(AuthRedisRepository) as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('signIn', () => { + it('사용자 로그인 성공 시 액세스 및 리프레시 토큰 반환 확인', async () => { + const mockUser: User = { id: 1, username: 'validUser', isGuest: false } as User; + userRepository.findOneBy.mockResolvedValue(mockUser); + + // signAsync의 첫 번째 호출은 accessToken, 두 번째 호출은 refreshToken을 반환하도록 설정 + jwtService.signAsync + .mockResolvedValueOnce('mockAccessToken') // 첫 번째 호출: accessToken + .mockResolvedValueOnce('mockRefreshToken'); // 두 번째 호출: refreshToken + + const result = await authService.signIn('validUser'); + + expect(userRepository.findOneBy).toHaveBeenCalledWith({ username: 'validUser' }); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 1, + { userId: 1, userName: 'validUser' }, + { secret: jwtConstants.secret, expiresIn: ACCESS_TOKEN_TTL } + ); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 2, + { userId: 1 }, + { secret: jwtConstants.refreshSecret, expiresIn: REFRESH_TOKEN_TTL } + ); + expect(authRedisRepository.setAuthData).toHaveBeenCalledWith('refresh:1', 'mockRefreshToken', REFRESH_TOKEN_TTL); + expect(result).toEqual({ access_token: 'mockAccessToken', refresh_token: 'mockRefreshToken' }); + }); + + it('존재하지 않는 사용자 로그인 시 UnauthorizedException 발생', async () => { + userRepository.findOneBy.mockResolvedValue(null); + + await expect(authService.signIn('invalidUser')).rejects.toThrow(UnauthorizedException); + expect(userRepository.findOneBy).toHaveBeenCalledWith({ username: 'invalidUser' }); + }); + }); + + describe('guestSignIn', () => { + it('게스트 로그인 시 토큰 반환 및 게스트 사용자 등록 확인', async () => { + const guestName = `guest_mock-uuid`; + const mockGuestUser: User = { id: 2, username: guestName, isGuest: true } as User; + + // signUp 메서드를 실제로 실행하게 하고, userRepository.save를 모킹 + userRepository.save.mockResolvedValue(mockGuestUser); + + // 첫 번째 findOneBy 호출 시 null 반환 (사용자 없음) + // 두 번째 findOneBy 호출 시 mockGuestUser 반환 + userRepository.findOneBy + .mockResolvedValueOnce(null) // 사용자 존재하지 않음 + .mockResolvedValueOnce(mockGuestUser); // 사용자 존재 + + // signAsync 호출 시 accessToken과 refreshToken을 반환하도록 설정 + jwtService.signAsync + .mockResolvedValueOnce('mockAccessToken') // accessToken + .mockResolvedValueOnce('mockRefreshToken'); // refreshToken + + // cacheGuestUser 메서드도 정상적으로 수행되도록 모킹 + authRedisRepository.setAuthData.mockResolvedValue(undefined); + + const result = await authService.guestSignIn(); + + expect(uuidv4).toHaveBeenCalled(); + // signUp 메서드를 직접 모킹하지 않으므로, signUp이 호출되었는지 확인할 필요 없음 + expect(userRepository.findOneBy).toHaveBeenCalledWith({ username: guestName }); + expect(userRepository.save).toHaveBeenCalledWith({ + username: guestName, + email: undefined, // 게스트 사용자일 경우 email이 undefined일 수 있음 + provider: undefined, // 게스트 사용자의 provider가 'local'로 설정되어 있을 수 있음 + providerId: undefined, // 게스트 사용자의 providerId가 undefined일 수 있음 + isGuest: true, + }); + expect(authRedisRepository.setAuthData).toHaveBeenCalledWith('guest:2', JSON.stringify({ userId: 2 }), GUEST_ID_TTL); + expect(jwtService.signAsync).toHaveBeenCalledTimes(2); + expect(result).toEqual({ access_token: 'mockAccessToken', refresh_token: 'mockRefreshToken' }); + }); + }); + + + describe('signUp', () => { + it('신규 사용자 등록이 정상적으로 수행되는지 확인', async () => { + const signUpDto: SignUpDto = { name: 'newUser', email: 'new@example.com', provider: 'local', providerId: '12345', isGuest: false }; + const mockSavedUser: User = { id: 3, username: 'newUser', email: 'new@example.com', isGuest: false } as User; + + userRepository.findOne.mockResolvedValue(null); // 사용자 없음 + userRepository.save.mockResolvedValue(mockSavedUser); + accountRepository.save.mockResolvedValue(null); // 계정 저장 성공 + + const result = await authService.signUp(signUpDto); + + expect(userRepository.findOne).toHaveBeenCalledWith({ where: { provider: 'local', providerId: '12345' } }); + + // signUpDto의 'name'이 'username'으로 매핑되어 save 호출됨 + expect(userRepository.save).toHaveBeenCalledWith({ + username: 'newUser', + email: 'new@example.com', + provider: 'local', + providerId: '12345', + isGuest: false, + }); + + expect(accountRepository.save).toHaveBeenCalledWith({ + user: mockSavedUser, + KRW: DEFAULT_KRW, + availableKRW: DEFAULT_KRW, + USDT: DEFAULT_USDT, + BTC: DEFAULT_BTC, + }); + expect(result).toEqual({ message: 'User successfully registered' }); + }); + + it('이미 존재하는 사용자 등록 시 ConflictException 발생', async () => { + const signUpDto: SignUpDto = { name: 'existingUser', email: 'existing@example.com', provider: 'local', providerId: 'existing123', isGuest: false }; + const mockExistingUser: User = { id: 4, username: 'existingUser', email: 'existing@example.com', isGuest: false } as User; + + userRepository.findOne.mockResolvedValue(mockExistingUser); // 사용자 존재 + + await expect(authService.signUp(signUpDto)).rejects.toThrow(ConflictException); + expect(userRepository.findOne).toHaveBeenCalledWith({ where: { provider: 'local', providerId: 'existing123' } }); + }); + }); + + describe('validateOAuthLogin', () => { + it('OAuth 로그인 시 기존 사용자에 대한 토큰 반환 확인', async () => { + const signUpDto: SignUpDto = { provider: 'google', providerId: 'google123', name: 'googleUser', isGuest: false }; + const mockUser: User = { id: 5, username: 'googleUser', isGuest: false } as User; + + userRepository.findOne.mockResolvedValue(mockUser); + + // signAsync의 첫 번째 호출은 accessToken, 두 번째 호출은 refreshToken을 반환하도록 설정 + jwtService.signAsync + .mockResolvedValueOnce('mockAccessToken') // 첫 번째 호출: accessToken + .mockResolvedValueOnce('mockRefreshToken'); // 두 번째 호출: refreshToken + + const result = await authService.validateOAuthLogin(signUpDto); + + expect(userRepository.findOne).toHaveBeenCalledWith({ where: { provider: 'google', providerId: 'google123' } }); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 1, + { userId: 5, userName: 'googleUser' }, + { secret: jwtConstants.secret, expiresIn: ACCESS_TOKEN_TTL } + ); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 2, + { userId: 5 }, + { secret: jwtConstants.refreshSecret, expiresIn: REFRESH_TOKEN_TTL } + ); + expect(authRedisRepository.setAuthData).toHaveBeenCalledWith('refresh:5', 'mockRefreshToken', REFRESH_TOKEN_TTL); + expect(result).toEqual({ access_token: 'mockAccessToken', refresh_token: 'mockRefreshToken' }); + }); + + it('OAuth 로그인 시 신규 사용자 등록 후 토큰 반환 확인', async () => { + const signUpDto: SignUpDto = { provider: 'facebook', providerId: 'fb123', name: 'fbUser', isGuest: false }; + const mockUser: User = { id: 6, username: 'fbUser', isGuest: false } as User; + + // 첫 번째 findOne 호출 시 null (사용자 없음), 두 번째 호출 시 mockUser + userRepository.findOne + .mockResolvedValueOnce(null) // 사용자 없음 + .mockResolvedValueOnce(mockUser); // 사용자 존재 + + // signUp 메서드 모킹 + authService.signUp = jest.fn().mockResolvedValue({ message: 'User successfully registered' }); + + // signAsync 호출 시 accessToken과 refreshToken을 반환하도록 설정 + jwtService.signAsync + .mockResolvedValueOnce('mockAccessToken') // 첫 번째 호출: accessToken + .mockResolvedValueOnce('mockRefreshToken'); // 두 번째 호출: refreshToken + + const result = await authService.validateOAuthLogin(signUpDto); + + expect(authService.signUp).toHaveBeenCalledWith(signUpDto); + expect(userRepository.findOne).toHaveBeenCalledWith({ where: { provider: 'facebook', providerId: 'fb123' } }); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 1, + { userId: 6, userName: 'fbUser' }, + { secret: jwtConstants.secret, expiresIn: ACCESS_TOKEN_TTL } + ); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 2, + { userId: 6 }, + { secret: jwtConstants.refreshSecret, expiresIn: REFRESH_TOKEN_TTL } + ); + expect(authRedisRepository.setAuthData).toHaveBeenCalledWith('refresh:6', 'mockRefreshToken', REFRESH_TOKEN_TTL); + expect(result).toEqual({ access_token: 'mockAccessToken', refresh_token: 'mockRefreshToken' }); + }); + + it('OAuth 사용자 생성 실패 시 UnauthorizedException 발생', async () => { + const signUpDto: SignUpDto = { provider: 'github', providerId: 'gh123', name: 'ghUser', isGuest: false }; + + // 첫 번째 findOne 호출 시 null (사용자 없음), 두 번째 호출 시 null (사용자 생성 실패) + userRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + // signUp 메서드 모킹 + authService.signUp = jest.fn().mockResolvedValue({ message: 'User successfully registered' }); + + await expect(authService.validateOAuthLogin(signUpDto)).rejects.toThrow(UnauthorizedException); + expect(authService.signUp).toHaveBeenCalledWith(signUpDto); + expect(userRepository.findOne).toHaveBeenCalledWith({ where: { provider: 'github', providerId: 'gh123' } }); + }); + }); + + describe('refreshTokens', () => { + it('유효한 리프레시 토큰으로 액세스 및 리프레시 토큰 재발급 확인', async () => { + const payload = { userId: 7 }; + jwtService.verifyAsync.mockResolvedValue(payload); + authRedisRepository.getAuthData.mockResolvedValue('validRefreshToken'); + const mockUser: User = { id: 7, username: 'refreshUser', isGuest: false } as User; + + userRepository.findOneBy.mockResolvedValue(mockUser); + + // signAsync의 첫 번째 호출은 accessToken, 두 번째 호출은 refreshToken을 반환하도록 설정 + jwtService.signAsync + .mockResolvedValueOnce('newAccessToken') // 첫 번째 호출: accessToken + .mockResolvedValueOnce('newRefreshToken'); // 두 번째 호출: refreshToken + + const result = await authService.refreshTokens('validRefreshToken'); + + expect(jwtService.verifyAsync).toHaveBeenCalledWith('validRefreshToken', { secret: jwtConstants.refreshSecret }); + expect(authRedisRepository.getAuthData).toHaveBeenCalledWith('refresh:7'); + expect(userRepository.findOneBy).toHaveBeenCalledWith({ id: 7 }); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 1, + { userId: 7, userName: 'refreshUser' }, + { secret: jwtConstants.secret, expiresIn: ACCESS_TOKEN_TTL } + ); + expect(jwtService.signAsync).toHaveBeenNthCalledWith( + 2, + { userId: 7 }, + { secret: jwtConstants.refreshSecret, expiresIn: REFRESH_TOKEN_TTL } + ); + expect(authRedisRepository.setAuthData).toHaveBeenCalledWith('refresh:7', 'newRefreshToken', REFRESH_TOKEN_TTL); + expect(result).toEqual({ access_token: 'newAccessToken', refresh_token: 'newRefreshToken' }); + }); + + it('무효한 리프레시 토큰 시 UnauthorizedException 발생', async () => { + jwtService.verifyAsync.mockRejectedValue(new Error('Invalid token')); + + await expect(authService.refreshTokens('invalidRefreshToken')).rejects.toThrow(UnauthorizedException); + expect(jwtService.verifyAsync).toHaveBeenCalledWith('invalidRefreshToken', { secret: jwtConstants.refreshSecret }); + }); + }); + + describe('logout', () => { + it('정상적인 로그아웃 시 성공 메시지 반환 및 토큰 삭제 확인', async () => { + const mockUser: User = { id: 8, username: 'regularUser', isGuest: false } as User; + userRepository.findOneBy.mockResolvedValue(mockUser); + + const result = await authService.logout(8); + + expect(authRedisRepository.deleteAuthData).toHaveBeenCalledWith('refresh:8'); + expect(userRepository.findOneBy).toHaveBeenCalledWith({ id: 8 }); + expect(result).toEqual({ message: 'User logged out successfully' }); + }); + + it('게스트 사용자의 로그아웃 시 계정 삭제 및 성공 메시지 반환', async () => { + const mockGuestUser: User = { id: 9, username: 'guestUser', isGuest: true } as User; + userRepository.findOneBy.mockResolvedValue(mockGuestUser); + userRepository.delete.mockResolvedValue(null); + + const result = await authService.logout(9); + + expect(authRedisRepository.deleteAuthData).toHaveBeenCalledWith('refresh:9'); + expect(userRepository.findOneBy).toHaveBeenCalledWith({ id: 9 }); + expect(userRepository.delete).toHaveBeenCalledWith({ id: 9 }); + expect(result).toEqual({ message: 'Guest user data successfully deleted' }); + }); + }); +}); diff --git a/packages/server/test/auth/constant.ts b/packages/server/test/auth/constant.ts new file mode 100644 index 00000000..d388e637 --- /dev/null +++ b/packages/server/test/auth/constant.ts @@ -0,0 +1,11 @@ +export const jwtConstants = { + secret: 'superSecureAccessTokenSecret', + refreshSecret: 'superSecureRefreshTokenSecret', + }; + export const ACCESS_TOKEN_TTL = '15m'; // 액세스 토큰의 유효 기간 + export const REFRESH_TOKEN_TTL = '7d'; // 리프레시 토큰의 유효 기간 + export const DEFAULT_KRW = 1000000; + export const DEFAULT_USDT = 1000; + export const DEFAULT_BTC = 0.1; + export const GUEST_ID_TTL = '1h'; // 게스트 ID의 유효 기간 + \ No newline at end of file diff --git a/packages/server/test/auth/user.service.spec.ts b/packages/server/test/auth/user.service.spec.ts index e69de29b..627d006c 100644 --- a/packages/server/test/auth/user.service.spec.ts +++ b/packages/server/test/auth/user.service.spec.ts @@ -0,0 +1,482 @@ +// test/auth/user.service.spec.ts + +import { Test, TestingModule } from '@nestjs/testing'; +import { UserService } from '../../src/auth/user.service'; +import { UserRepository } from '../../src/auth/user.repository'; +import { TradeRepository } from '../../src/trade/trade.repository'; +import { TradeHistoryRepository } from '../../src/trade-history/trade-history.repository'; +import { AccountRepository } from '@src/account/account.repository'; +import { AccountService } from '@src/account/account.service'; +import { + DataSource, + EntityManager, + FindOneOptions, +} from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { User } from '../../src/auth/user.entity'; +import { Account } from '@src/account/account.entity'; +import { DEFAULT_BTC, DEFAULT_KRW, DEFAULT_USDT } from '../../src/auth/constants'; + +// === 1. EntityManager를 정확히 모킹하기 위한 함수 === +function createMockedEntityManager(): Partial { + return { + findOne: jest.fn(), + delete: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; +} + +describe('UserService', () => { + let userService: UserService; + let userRepository: jest.Mocked; + let tradeRepository: jest.Mocked; + let tradeHistoryRepository: jest.Mocked; + let accountRepository: jest.Mocked; + let accountService: jest.Mocked; + let dataSource: jest.Mocked; + let entityManager: Partial; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: UserRepository, + useValue: { + target: User, + find: jest.fn(), + findOne: jest.fn(), + findOneBy: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: TradeRepository, + useValue: { + target: 'Trade', + // 필요한 메서드 모킹 + }, + }, + { + provide: TradeHistoryRepository, + useValue: { + target: 'TradeHistory', + // 필요한 메서드 모킹 + }, + }, + { + provide: AccountRepository, + useValue: { + target: Account, + findOne: jest.fn(), + // 필요한 메서드 모킹 + }, + }, + { + provide: AccountService, + useValue: { + getEvaluatedAssets: jest.fn(), + // 필요한 메서드 모킹 + }, + }, + { + provide: DataSource, + useFactory: () => ({ + transaction: jest.fn(), + }), + }, + ], + }).compile(); + + userService = module.get(UserService); + userRepository = module.get(UserRepository) as jest.Mocked; + tradeRepository = module.get(TradeRepository) as jest.Mocked; + tradeHistoryRepository = module.get(TradeHistoryRepository) as jest.Mocked; + accountRepository = module.get(AccountRepository) as jest.Mocked; + accountService = module.get(AccountService) as jest.Mocked; + dataSource = module.get(DataSource) as jest.Mocked; + + // === 2. 정확히 모킹된 EntityManager 생성 === + entityManager = createMockedEntityManager(); + + // === 3. DataSource.transaction 모킹 === + dataSource.transaction.mockImplementation(async (cb: any, isolationLevel?: any) => { + // Handle both signatures + if (typeof cb === 'function') { + return cb(entityManager as EntityManager); + } + if (typeof isolationLevel === 'function') { + return isolationLevel(entityManager as EntityManager); + } + throw new Error('Invalid arguments to transaction'); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + + + describe('resetUserData', () => { + it('성공적으로 사용자 데이터를 초기화하고 새 계정을 생성하는지 확인', async () => { + const userId = 1; + const mockUser: User = { + id: userId, + username: 'testUser', + isGuest: false, + account: { id: 10 } as Account, + } as User; + + const mockNewAccount: Account = { + id: 11, + KRW: DEFAULT_KRW, + availableKRW: DEFAULT_KRW, + USDT: DEFAULT_USDT, + BTC: DEFAULT_BTC, + } as Account; + + // === 4. EntityManager 메서드 모킹 === + (entityManager.findOne as jest.Mock).mockResolvedValue(mockUser); + (entityManager.delete as jest.Mock).mockResolvedValue(undefined); + + // === 5. EntityManager.create 메서드를 정확히 모킹 === + (entityManager.create as jest.Mock).mockImplementation((entityClass: any, plainObject: any) => { + if (entityClass === Account) { + return mockNewAccount; + } + return null; + }); + + // === 5.1. EntityManager.save 메서드 모킹: user.account를 새 계정으로 업데이트 === + (entityManager.save as jest.Mock).mockImplementation((entityClass: any, user: User) => { + if (entityClass === User) { + user.account = mockNewAccount; + } + return Promise.resolve(user); + }); + + // === 5.2. 기존 계정 ID 저장 === + const oldAccountId = mockUser.account.id; + + // === 5.3. 서비스 메서드 호출 === + await userService.resetUserData(userId); + + // 사용자 조회 확인 + expect(entityManager.findOne).toHaveBeenCalledWith(User, { + where: { id: userId }, + relations: ['account'], + }); + + // === 6. delete 메서드 호출 시 부분 매칭 적용 === + const deleteCalls = entityManager.delete as jest.MockedFunction; + expect(deleteCalls).toHaveBeenCalledTimes(3); + + // 'Trade' 삭제 호출 확인 + expect(deleteCalls).toHaveBeenNthCalledWith(1, 'Trade', expect.objectContaining({ + user: expect.objectContaining({ id: userId }) + })); + + // 'TradeHistory' 삭제 호출 확인 + expect(deleteCalls).toHaveBeenNthCalledWith(2, 'TradeHistory', expect.objectContaining({ + user: expect.objectContaining({ id: userId }) + })); + + // 'Account' 삭제 호출 확인 (기존 계정 ID 사용) + expect(deleteCalls).toHaveBeenNthCalledWith(3, Account, expect.objectContaining({ + id: oldAccountId + })); + + // 사용자 계정 null로 설정 및 저장 확인 + // 첫 번째 save 호출: user.account = null + // 두 번째 save 호출: user.account = mockNewAccount + // 여기서는 save가 두 번 호출되지 않도록 해야 할 수도 있음 + // 하지만 현재 mock.save only calls once, assigning mockNewAccount + + // 새 계정 생성 및 저장 확인 + expect(entityManager.create).toHaveBeenCalledWith(Account, { + KRW: DEFAULT_KRW, + availableKRW: DEFAULT_KRW, + USDT: DEFAULT_USDT, + BTC: DEFAULT_BTC, + }); + expect(mockUser.account).toBe(mockNewAccount); + expect(entityManager.save).toHaveBeenCalledWith(User, mockUser); + }); + + it('사용자가 존재하지 않을 때 NotFoundException을 던지는지 확인', async () => { + const userId = 2; + + (entityManager.findOne as jest.Mock).mockResolvedValue(null); + + await expect(userService.resetUserData(userId)).rejects.toThrow(NotFoundException); + expect(entityManager.findOne).toHaveBeenCalledWith(User, { + where: { id: userId }, + relations: ['account'], + }); + + // 나머지 메서드는 호출되지 않아야 함 + expect(entityManager.delete).not.toHaveBeenCalled(); + expect(entityManager.create).not.toHaveBeenCalled(); + expect(entityManager.save).not.toHaveBeenCalled(); + }); + + it('트랜잭션 내에서 오류가 발생하면 예외를 던지는지 확인', async () => { + const userId = 3; + const mockUser: User = { + id: userId, + username: 'errorUser', + isGuest: false, + account: { id: 20 } as Account, + } as User; + + (entityManager.findOne as jest.Mock).mockResolvedValue(mockUser); + (entityManager.delete as jest.Mock).mockRejectedValue(new Error('Delete failed')); + + await expect(userService.resetUserData(userId)).rejects.toThrow(Error); + expect(entityManager.findOne).toHaveBeenCalledWith(User, { + where: { id: userId }, + relations: ['account'], + }); + + // 'Trade' 삭제 호출 확인 + expect(entityManager.delete).toHaveBeenCalledWith('Trade', expect.objectContaining({ + user: expect.objectContaining({ id: userId }) + })); + + // 'TradeHistory' 삭제는 호출되지 않아야 함 + expect(entityManager.delete).not.toHaveBeenCalledWith('TradeHistory', expect.objectContaining({ + user: expect.objectContaining({ id: userId }) + })); + + // 이후 메서드 호출은 중단됨 + expect(entityManager.create).not.toHaveBeenCalled(); + expect(entityManager.save).not.toHaveBeenCalled(); + }); + }); + + describe('getAllUsersInfo', () => { + it('모든 사용자의 id와 username을 반환하는지 확인', async () => { + const mockUsers: User[] = [ + { id: 1, username: 'user1' } as User, + { id: 2, username: 'user2' } as User, + ]; + + userRepository.find.mockResolvedValue(mockUsers); + + const result = await userService.getAllUsersInfo(); + + expect(userRepository.find).toHaveBeenCalledWith({ + select: ['id', 'username'], + }); + + expect(result).toEqual([ + { id: 1, username: 'user1' }, + { id: 2, username: 'user2' }, + ]); + }); + + it('사용자가 없을 때 빈 배열을 반환하는지 확인', async () => { + userRepository.find.mockResolvedValue([]); + + const result = await userService.getAllUsersInfo(); + + expect(userRepository.find).toHaveBeenCalledWith({ + select: ['id', 'username'], + }); + + expect(result).toEqual([]); + }); + + it('사용자 조회 중 오류가 발생하면 예외를 던지는지 확인', async () => { + userRepository.find.mockRejectedValue(new Error('Database error')); + + await expect(userService.getAllUsersInfo()).rejects.toThrow(Error); + expect(userRepository.find).toHaveBeenCalledWith({ + select: ['id', 'username'], + }); + }); + }); + + describe('getAllUsersInfoWithTotalAsset', () => { + it('모든 사용자의 자산 정보를 포함하여 반환하는지 확인', async () => { + const mockUsers: User[] = [ + { id: 1, username: 'user1' } as User, + { id: 2, username: 'user2' } as User, + ]; + + const mockAccounts: Account[] = [ + { id: 100, user: { id: 1 } as User } as Account, + { id: 200, user: { id: 2 } as User } as Account, + ]; + + const mockAssetDataUser1 = { + totalAsset: 100000, + KRW: 50000, + coinEvaluations: [{ coin: 'BTC', value: 50000 }], + }; + + const mockAssetDataUser2 = { + totalAsset: 200000, + KRW: 100000, + coinEvaluations: [{ coin: 'ETH', value: 100000 }], + }; + + userRepository.find.mockResolvedValue(mockUsers); + + accountRepository.findOne.mockImplementation((options: FindOneOptions) => { + if (!options.where || Array.isArray(options.where)) { + return Promise.resolve(null); + } + const userId = (options.where as { user?: { id: number } }).user?.id; + if (userId === 1) return Promise.resolve(mockAccounts[0]); + if (userId === 2) return Promise.resolve(mockAccounts[1]); + return Promise.resolve(null); + }); + + accountService.getEvaluatedAssets.mockImplementation((accountId: number) => { + if (accountId === 100) return Promise.resolve(mockAssetDataUser1); + if (accountId === 200) return Promise.resolve(mockAssetDataUser2); + return Promise.resolve({ totalAsset: 0, KRW: 0, coinEvaluations: [] }); + }); + + const result = await userService.getAllUsersInfoWithTotalAsset(); + + expect(userRepository.find).toHaveBeenCalledWith({ + select: ['id', 'username'], + }); + + expect(accountRepository.findOne).toHaveBeenCalledTimes(2); + expect(accountRepository.findOne).toHaveBeenCalledWith({ + where: { user: { id: 1 } }, + }); + expect(accountRepository.findOne).toHaveBeenCalledWith({ + where: { user: { id: 2 } }, + }); + + expect(accountService.getEvaluatedAssets).toHaveBeenCalledWith(100); + expect(accountService.getEvaluatedAssets).toHaveBeenCalledWith(200); + + expect(result).toEqual([ + { + id: 1, + username: 'user1', + totalAsset: 100000, + KRW: 50000, + coinEvaluations: [{ coin: 'BTC', value: 50000 }], + }, + { + id: 2, + username: 'user2', + totalAsset: 200000, + KRW: 100000, + coinEvaluations: [{ coin: 'ETH', value: 100000 }], + }, + ]); + }); + + it('일부 사용자에게 계정이 없을 때 경고 로그를 남기고 기본 자산 정보를 반환하는지 확인', async () => { + const mockUsers: User[] = [ + { id: 1, username: 'user1' } as User, + { id: 2, username: 'user2' } as User, + { id: 3, username: 'user3' } as User, + ]; + const mockAccounts: Account[] = [ + { id: 100, user: { id: 1 } as User } as Account, + // user2는 계정 없음 + { id: 300, user: { id: 3 } as User } as Account, + ]; + const mockAssetDataUser1 = { + totalAsset: 150000, + KRW: 75000, + coinEvaluations: [{ coin: 'BTC', value: 75000 }], + }; + const mockAssetDataUser3 = { + totalAsset: 300000, + KRW: 150000, + coinEvaluations: [{ coin: 'ETH', value: 150000 }], + }; + + userRepository.find.mockResolvedValue(mockUsers); + + accountRepository.findOne.mockImplementation((options: FindOneOptions) => { + if (!options.where || Array.isArray(options.where)) { + return Promise.resolve(null); + } + const userId = (options.where as { user?: { id: number } }).user?.id; + if (userId === 1) return Promise.resolve(mockAccounts[0]); + if (userId === 3) return Promise.resolve(mockAccounts[1]); + return Promise.resolve(null); + }); + + accountService.getEvaluatedAssets.mockImplementation((accountId: number) => { + if (accountId === 100) return Promise.resolve(mockAssetDataUser1); + if (accountId === 300) return Promise.resolve(mockAssetDataUser3); + return Promise.resolve({ totalAsset: 0, KRW: 0, coinEvaluations: [] }); + }); + + // === 7. Logger를 스파이하여 경고 로그 확인 === + const loggerSpy = jest.spyOn(userService['logger'], 'warn').mockImplementation(() => {}); + + const result = await userService.getAllUsersInfoWithTotalAsset(); + + expect(userRepository.find).toHaveBeenCalledWith({ + select: ['id', 'username'], + }); + + expect(accountRepository.findOne).toHaveBeenCalledTimes(3); + expect(accountRepository.findOne).toHaveBeenCalledWith({ + where: { user: { id: 1 } }, + }); + expect(accountRepository.findOne).toHaveBeenCalledWith({ + where: { user: { id: 2 } }, + }); + expect(accountRepository.findOne).toHaveBeenCalledWith({ + where: { user: { id: 3 } }, + }); + + expect(accountService.getEvaluatedAssets).toHaveBeenCalledWith(100); + expect(accountService.getEvaluatedAssets).toHaveBeenCalledWith(300); + + // user2에 대한 경고 로그 확인 + expect(loggerSpy).toHaveBeenCalledWith('Account not found for userId: 2'); + + expect(result).toEqual([ + { + id: 1, + username: 'user1', + totalAsset: 150000, + KRW: 75000, + coinEvaluations: [{ coin: 'BTC', value: 75000 }], + }, + { + id: 2, + username: 'user2', + totalAsset: 0, + KRW: 0, + coinEvaluations: [], + }, + { + id: 3, + username: 'user3', + totalAsset: 300000, + KRW: 150000, + coinEvaluations: [{ coin: 'ETH', value: 150000 }], + }, + ]); + + // === 8. Logger 스파이 복원 === + loggerSpy.mockRestore(); + }); + + it('사용자 조회 중 오류가 발생하면 예외를 던지는지 확인', async () => { + userRepository.find.mockRejectedValue(new Error('Database error')); + await expect(userService.getAllUsersInfoWithTotalAsset()).rejects.toThrow(Error); + expect(userRepository.find).toHaveBeenCalledWith({ + select: ['id', 'username'], + }); + }); + }); +});