diff --git a/BE/src/auth/auth.service.spec.ts b/BE/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..f7adba0a --- /dev/null +++ b/BE/src/auth/auth.service.spec.ts @@ -0,0 +1,188 @@ +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; +import { config } from 'dotenv'; +import { UnauthorizedException } from '@nestjs/common'; +import { User } from './user.entity'; +import { AuthCredentialsDto } from './dto/auth-credentials.dto'; +import { UserRepository } from './user.repository'; +import { AuthService } from './auth.service'; + +config(); + +describe('auth service 테스트', () => { + let authService: AuthService; + let userRepository: UserRepository; + let jwtService: JwtService; + let configService: ConfigService; + + const mockAuthCredentials: AuthCredentialsDto = { + email: 'jindding', + password: '1234', + }; + + const mockUser: User = { + id: 1, + email: 'jindding', + password: '1234', + tutorial: false, + kakaoId: '', + currentRefreshToken: 'validRefreshToken', + currentRefreshTokenExpiresAt: null, + toAuthCredentialsDto: jest.fn().mockReturnValue({ + email: 'jindding', + password: '1234', + }), + nickname: 'testNickname', + hasId: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + recover: jest.fn(), + reload: jest.fn(), + }; + + beforeEach(async () => { + const mockUserRepository = { + registerUser: jest.fn(), + registerKakaoUser: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + verify: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: UserRepository, + useValue: mockUserRepository, + }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + authService = module.get(AuthService); + userRepository = module.get(UserRepository); + jwtService = module.get(JwtService); + configService = module.get(ConfigService); + }); + + it('회원가입 요청 시, DB에 회원 정보가 저장된다.', async () => { + const registerUserSpy = jest.spyOn(userRepository, 'registerUser'); + await authService.signUp(mockAuthCredentials).then(() => { + expect(registerUserSpy).toHaveBeenCalledWith(mockAuthCredentials); + }); + }); + + describe('로그인 테스트', () => { + it('DB에 존재하는 이메일과 비밀번호로 로그인 시, 토큰이 발급된다.', async () => { + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser); + jest.spyOn(jwtService, 'signAsync').mockResolvedValue('token'); + /* eslint-disable @typescript-eslint/no-misused-promises */ + /* eslint-disable func-names */ + jest.spyOn(bcrypt, 'compare').mockImplementation(function () { + return Promise.resolve(true); + }); + jest.spyOn(configService, 'get').mockReturnValue(process.env.JWT_SECRET); + + const result = await authService.loginUser(mockAuthCredentials); + + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + }); + + it('DB에 존재하지 않는 이메일로 로그인 시, 에러가 발생한다.', async () => { + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + + await expect( + authService.loginUser(mockAuthCredentials), + ).rejects.toThrow(); + }); + }); + + describe('카카오 로그인 테스트', () => { + it('첫 카카오 로그인 시, 회원 가입 후 토큰이 발급된다.', async () => { + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(userRepository, 'registerKakaoUser') + .mockResolvedValue(undefined); + jest.spyOn(jwtService, 'signAsync').mockResolvedValue('token'); + + const result = await authService.kakaoLoginUser(mockAuthCredentials); + + expect( + jest.spyOn(userRepository, 'registerKakaoUser'), + ).toHaveBeenCalled(); + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + }); + + it('이미 가입된 카카오 계정으로 로그인 시, 토큰이 발급된다.', async () => { + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser); + jest.spyOn(jwtService, 'signAsync').mockResolvedValue('token'); + + const result = await authService.kakaoLoginUser(mockAuthCredentials); + + expect(jest.spyOn(userRepository, 'registerUser')).not.toHaveBeenCalled(); + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + }); + }); + + describe('토큰 발급 테스트', () => { + it('유효한 리프레시 토큰으로 요청 시 , 새로운 액세스 토큰이 발급된다.', async () => { + const decodedToken = { email: mockUser.email }; + const mockRefreshToken = mockUser.currentRefreshToken; + + jest.spyOn(jwtService, 'verify').mockReturnValue(decodedToken); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser); + jest + .spyOn(bcrypt, 'compare') + .mockImplementation(() => Promise.resolve(true)); + jest.spyOn(jwtService, 'signAsync').mockResolvedValue('newAccessToken'); + jest.spyOn(configService, 'get').mockReturnValue(process.env.JWT_SECRET); + + const result = await authService.refreshToken(mockRefreshToken); + + expect(result).toBe('newAccessToken'); + }); + + it('유효하지 않은 리프레시 토큰으로 요청 시, 에러가 발생한다.', async () => { + jest.spyOn(jwtService, 'verify').mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await expect(authService.refreshToken('invalidToken')).rejects.toThrow(); + }); + + it('저장된 리프레시 토큰과 일치하지 않을 경우, UnauthorizedException이 발생한다.', async () => { + const decodedToken = { email: mockUser.email }; + jest.spyOn(jwtService, 'verify').mockReturnValue(decodedToken); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser); + jest + .spyOn(bcrypt, 'compare') + .mockImplementation(() => Promise.resolve(false)); + + await expect(authService.refreshToken('invalidToken')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/BE/src/ranking/ranking.service.spec.ts b/BE/src/ranking/ranking.service.spec.ts new file mode 100644 index 00000000..4bde8193 --- /dev/null +++ b/BE/src/ranking/ranking.service.spec.ts @@ -0,0 +1,258 @@ +import { Test } from '@nestjs/testing'; +import { RankingService } from './ranking.service'; +import { RedisDomainService } from '../common/redis/redis.domain-service'; +import { AssetRepository } from '../asset/asset.repository'; +import { SortType } from './enum/sort-type.enum'; + +describe('랭킹 서비스 테스트', () => { + let rankingService: RankingService; + let assetRepository: AssetRepository; + let redisDomainService: RedisDomainService; + + const mockAssets = [ + { + id: 1, + user_id: '1', + nickname: 'user1', + total_asset: 15000000, + prev_total_asset: 10000000, + }, + { + id: 2, + user_id: '2', + nickname: 'user2', + total_asset: 12000000, + prev_total_asset: 10000000, + }, + { + id: 3, + user_id: '3', + nickname: 'user3', + total_asset: 8000000, + prev_total_asset: 10000000, + }, + ]; + + beforeEach(async () => { + const mockAssetRepository = { + getAssets: jest.fn(), + }; + + const mockRedisDomainService = { + exists: jest.fn(), + zadd: jest.fn(), + zrange: jest.fn(), + zrevrange: jest.fn(), + zrevrank: jest.fn(), + del: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + RankingService, + { provide: AssetRepository, useValue: mockAssetRepository }, + { provide: RedisDomainService, useValue: mockRedisDomainService }, + ], + }).compile(); + + rankingService = module.get(RankingService); + assetRepository = module.get(AssetRepository); + redisDomainService = module.get(RedisDomainService); + }); + + describe('로그인 되지 않은 유저 전체 랭킹 조회 테스트', () => { + it('랭킹 조회시, 수익률과 총자산 랭킹이 모두 반환된다.', async () => { + jest + .spyOn(redisDomainService, 'exists') + .mockResolvedValue(Promise.resolve(1)); + + jest.spyOn(redisDomainService, 'zrevrange').mockResolvedValue([ + JSON.stringify({ + userId: '1', + nickname: 'user1', + value: 50, + }), + JSON.stringify({ + userId: '2', + nickname: 'user2', + value: 20, + }), + JSON.stringify({ + userId: '3', + nickname: 'user3', + value: -20, + }), + ]); + + const result = await rankingService.getRanking(); + + expect(result).toHaveProperty('profitRateRanking'); + expect(result).toHaveProperty('assetRanking'); + expect(result.profitRateRanking.topRank).toHaveLength(3); + expect(result.assetRanking.topRank).toHaveLength(3); + }); + + it('Redis에 랭킹 데이터가 없을 경우, 새로 계산하여 저장한다.', async () => { + jest + .spyOn(redisDomainService, 'exists') + .mockResolvedValue(Promise.resolve(0)); + + jest.spyOn(assetRepository, 'getAssets').mockResolvedValue(mockAssets); + const zaddSpy = jest + .spyOn(redisDomainService, 'zadd') + .mockResolvedValue(1); + + jest.spyOn(redisDomainService, 'zrevrange').mockResolvedValue([ + JSON.stringify({ + userId: '1', + nickname: 'user1', + value: 50, + }), + JSON.stringify({ + userId: '2', + nickname: 'user2', + value: 20, + }), + JSON.stringify({ + userId: '3', + nickname: 'user3', + value: -20, + }), + ]); + + await rankingService.getRanking(); + expect(zaddSpy).toHaveBeenCalled(); + }); + + describe('로그인 된 사용자 랭킹 조회 테스트', () => { + it('인증된 사용자의 랭킹 조회 시, 본인의 랭킹 정보도 함께 반환된다.', async () => { + const userId = '1'; + const mockProfitRateData = [ + JSON.stringify({ + userId: '1', + nickname: 'user1', + value: 50, + }), + JSON.stringify({ + userId: '2', + nickname: 'user2', + value: 20, + }), + JSON.stringify({ + userId: '3', + nickname: 'user3', + value: -20, + }), + ]; + + const mockAssetData = [ + JSON.stringify({ + userId: '1', + nickname: 'user1', + value: 15000000, + }), + JSON.stringify({ + userId: '2', + nickname: 'user2', + value: 12000000, + }), + JSON.stringify({ + userId: '3', + nickname: 'user3', + value: 8000000, + }), + ]; + + jest.spyOn(redisDomainService, 'exists').mockResolvedValue(1); + jest.spyOn(redisDomainService, 'zrevrange').mockResolvedValue([ + JSON.stringify({ + userId: '1', + nickname: 'user1', + value: 50, + }), + JSON.stringify({ + userId: '2', + nickname: 'user2', + value: 20, + }), + JSON.stringify({ + userId: '3', + nickname: 'user3', + value: -20, + }), + ]); + + const zrangeSpy = jest.spyOn(redisDomainService, 'zrange'); + zrangeSpy.mockImplementation((key) => { + if (key.includes('profitRate')) { + return Promise.resolve(mockProfitRateData); + } + return Promise.resolve(mockAssetData); + }); + jest.spyOn(redisDomainService, 'zrevrank').mockResolvedValue(0); + jest.spyOn(redisDomainService, 'zrevrange').mockResolvedValue([ + JSON.stringify({ + userId: '1', + nickname: 'user1', + value: 50, + }), + ]); + + const result = await rankingService.getRankingAuthUser(userId); + + expect(result).toHaveProperty('profitRateRanking'); + expect(result).toHaveProperty('assetRanking'); + expect(result.profitRateRanking.userRank.userId).toBe(userId); + expect(result.profitRateRanking.userRank.value).toBe(50); + expect(result.profitRateRanking.userRank.rank).toBe(1); + expect(result.assetRanking.userRank.userId).toBe(userId); + expect(result.assetRanking.userRank.rank).toBe(1); + expect(result.assetRanking.userRank.value).toBe(15000000); + }); + }); + + describe('랭킹 계산 테스트', () => { + it('수익률 기준으로 랭킹이 정렬된다.', async () => { + jest.spyOn(assetRepository, 'getAssets').mockResolvedValue(mockAssets); + + const result = await rankingService.calculateRanking( + SortType.PROFIT_RATE, + ); + + expect(result).toHaveLength(3); + expect(result[0].userId).toBe('1'); + expect(result[1].userId).toBe('2'); + expect(result[2].userId).toBe('3'); + }); + + it('총자산 기준으로 랭킹이 정렬된다', async () => { + jest.spyOn(assetRepository, 'getAssets').mockResolvedValue(mockAssets); + + const result = await rankingService.calculateRanking(SortType.ASSET); + + expect(result).toHaveLength(3); + expect(result[0].userId).toBe('1'); + expect(result[1].userId).toBe('2'); + expect(result[2].userId).toBe('3'); + }); + }); + + describe('랭킹 업데이트 테스트', () => { + it('랭킹 업데이트 시, Redis의 기존 데이터를 삭제하고 새로운 데이터를 저장한다.', async () => { + jest.spyOn(assetRepository, 'getAssets').mockResolvedValue(mockAssets); + + const delSpy = jest + .spyOn(redisDomainService, 'del') + .mockResolvedValue(1); + const zaddSpy = jest + .spyOn(redisDomainService, 'zadd') + .mockResolvedValue(1); + + await rankingService.updateRanking(); + + expect(delSpy).toHaveBeenCalledTimes(2); + expect(zaddSpy).toHaveBeenCalledTimes(6); + }); + }); + }); +}); diff --git a/BE/src/ranking/ranking.service.ts b/BE/src/ranking/ranking.service.ts index aa13a8b7..d5eeda68 100644 --- a/BE/src/ranking/ranking.service.ts +++ b/BE/src/ranking/ranking.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { RedisDomainService } from 'src/common/redis/redis.domain-service'; -import { AssetRepository } from 'src/asset/asset.repository'; import { Cron } from '@nestjs/schedule'; +import { RedisDomainService } from '../common/redis/redis.domain-service'; +import { AssetRepository } from '../asset/asset.repository'; import { SortType } from './enum/sort-type.enum'; import { Ranking } from './interface/ranking.interface'; import { RankingResponseDto } from './dto/ranking-response.dto'; diff --git a/BE/src/stock/list/stock-list.service.spec.ts b/BE/src/stock/list/stock-list.service.spec.ts new file mode 100644 index 00000000..5d8abe64 --- /dev/null +++ b/BE/src/stock/list/stock-list.service.spec.ts @@ -0,0 +1,159 @@ +import { Test } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { RedisDomainService } from '../../common/redis/redis.domain-service'; +import { StockListRepository } from './stock-list.repostiory'; +import { StockListService } from './stock-list.service'; +import { Stocks } from './stock-list.entity'; + +describe('주식 목록 조회 테스트', () => { + let stockListService: StockListService; + let stockListRepository: StockListRepository; + let redisDomainService: RedisDomainService; + + const mockStocks: Stocks[] = [ + { + code: '005930', + name: '삼성전자', + market: 'KOSPI', + hasId: () => true, + save: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + recover: jest.fn(), + reload: () => Promise.resolve(), + }, + { + code: '373220', + name: 'LG에너지솔루션', + market: 'KOSPI', + hasId: () => true, + save: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + recover: jest.fn(), + reload: () => Promise.resolve(), + }, + ]; + + beforeAll(async () => { + const mockStockListRepository = { + findAllStocks: jest.fn(), + findOneStock: jest.fn(), + search: jest.fn(), + }; + + const mockRedisDomainService = { + zadd: jest.fn(), + zcard: jest.fn(), + zremrangebyrank: jest.fn(), + zrevrange: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + StockListService, + { provide: StockListRepository, useValue: mockStockListRepository }, + { provide: RedisDomainService, useValue: mockRedisDomainService }, + ], + }).compile(); + + stockListService = module.get(StockListService); + stockListRepository = module.get(StockListRepository); + redisDomainService = module.get(RedisDomainService); + }); + + describe('주식 목록 조회 테스트', () => { + it('주식 목록을 조회하면, 주식 목록이 반환된다.', async () => { + jest + .spyOn(stockListRepository, 'findAllStocks') + .mockResolvedValue(mockStocks); + + const result = await stockListService.findAll(); + expect(result[0].code).toBe('005930'); + expect(result[0].name).toBe('삼성전자'); + expect(result[0].market).toBe('KOSPI'); + }); + + it('주식 코드로 조회 시, 해당 주식 정보가 반환된다.', async () => { + jest + .spyOn(stockListRepository, 'findOneStock') + .mockResolvedValue(mockStocks[0]); + + const result = await stockListService.findOne('005930'); + + expect(result.code).toBe('005930'); + expect(result.name).toBe('삼성전자'); + expect(result.market).toBe('KOSPI'); + }); + + it('존재하지 않는 주식 코드로 조회 시, NotFoundException이 발생한다.', async () => { + jest.spyOn(stockListRepository, 'findOneStock').mockResolvedValue(null); + await expect(stockListService.findOne('000000')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('주식 검색 테스트', () => { + it('검색 조건으로 조회 시, 조건에 맞는 주식 목록이 반환된다', async () => { + const searchParams = { name: '삼성' }; + jest + .spyOn(stockListRepository, 'search') + .mockResolvedValue([mockStocks[0]]); + + const result = await stockListService.search(searchParams); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('삼성전자'); + }); + }); + + describe('검색 기록 관리 테스트', () => { + it('로그인 된 유저가 검색 시, Redis에 검색 기록이 저장된다.', async () => { + const searchInfo = { userId: 1, searchTerm: '삼성' }; + + const zaddSpy = jest + .spyOn(redisDomainService, 'zadd') + .mockResolvedValue(1); + + const zrevrangeSpy = jest.spyOn(redisDomainService, 'zrevrange'); + + await stockListService.addSearchTermToRedis(searchInfo); + expect(zaddSpy).toHaveBeenCalled(); + expect(zrevrangeSpy).not.toHaveBeenCalled(); + }); + + it('검색 기록이 제한을 초과하면, 가장 오래된 기록이 삭제된다', async () => { + const searchInfo = { + userId: 1, + searchTerm: '삼성', + }; + + const zaddSpy = jest + .spyOn(redisDomainService, 'zadd') + .mockResolvedValue(1); + const zcardSpy = jest + .spyOn(redisDomainService, 'zcard') + .mockResolvedValue(11); + + await stockListService.addSearchTermToRedis(searchInfo); + + expect(zaddSpy).toHaveBeenCalled(); + expect(zcardSpy).toHaveBeenCalled(); + }); + + it('검색 기록 조회 시, 최근 검색어 목록이 반환된다', async () => { + const userId = '1'; + const mockSearchHistory = ['삼성', 'LG', 'SK']; + + jest + .spyOn(redisDomainService, 'zrevrange') + .mockResolvedValue(mockSearchHistory); + + const result = await stockListService.getSearchTermFromRedis(userId); + + expect(result).toEqual(mockSearchHistory); + expect(result).toHaveLength(3); + }); + }); +}); diff --git a/BE/src/stock/list/stock-list.service.ts b/BE/src/stock/list/stock-list.service.ts index 7e4c4956..b4213792 100644 --- a/BE/src/stock/list/stock-list.service.ts +++ b/BE/src/stock/list/stock-list.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { RedisDomainService } from 'src/common/redis/redis.domain-service'; +import { RedisDomainService } from '../../common/redis/redis.domain-service'; import { StockListRepository } from './stock-list.repostiory'; import { Stocks } from './stock-list.entity'; import { StockListResponseDto } from './dto/stock-list-response.dto';