From e66ce5a544774a9bf9fb515acc99f4d2ced86cdb Mon Sep 17 00:00:00 2001 From: jinlee Date: Fri, 27 Jun 2025 16:15:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20isa=20=EC=88=98=EC=9D=B5=EB=A5=A0?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/get-monthly-returns.test.ts | 89 +++++++++++++++++++ app/actions/get-monthly-returns.ts | 3 +- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 __tests__/services/get-monthly-returns.test.ts diff --git a/__tests__/services/get-monthly-returns.test.ts b/__tests__/services/get-monthly-returns.test.ts new file mode 100644 index 0000000..8681a74 --- /dev/null +++ b/__tests__/services/get-monthly-returns.test.ts @@ -0,0 +1,89 @@ +import { getServerSession } from 'next-auth'; +import { getMonthlyReturns } from '@/app/actions/get-monthly-returns'; +import { authOptions } from '@/lib/auth-options'; +import { prisma } from '@/lib/prisma'; + +jest.mock('next-auth'); +jest.mock('@/lib/prisma', () => ({ + prisma: { + iSAAccount: { + findUnique: jest.fn(), + }, + monthlyReturn: { + findMany: jest.fn(), + }, + eTFHoldingSnapshot: { + findMany: jest.fn(), + }, + generalHoldingSnapshot: { + aggregate: jest.fn(), + findMany: jest.fn(), + }, + }, +})); + +describe('getMonthlyReturns', () => { + const mockUserId = 1; + + beforeEach(() => { + jest.clearAllMocks(); + // 로그인된 사용자 세션을 모킹, user.id를 문자열로 설정 + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: mockUserId.toString() }, + }); + }); + + it('로그인하지 않은 경우 오류를 발생시킵니다', async () => { + // 로그인 세션이 없을 경우 null 반환하도록 모킹 + (getServerSession as jest.Mock).mockResolvedValueOnce(null); + // getMonthlyReturns 호출 시 "로그인이 필요합니다." 오류가 발생하는지 검증 + await expect(getMonthlyReturns('6')).rejects.toThrow( + '로그인이 필요합니다.' + ); + }); + + it('ISA 계좌가 없을 때 오류를 발생시킵니다', async () => { + // ISA 계좌 조회 시 null 반환하도록 모킹하여 계좌가 없음을 시뮬레이션 + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue(null); + // getMonthlyReturns 호출 시 "ISA 계좌가 없습니다." 오류가 발생하는지 검증 + await expect(getMonthlyReturns('6')).rejects.toThrow( + 'ISA 계좌가 없습니다.' + ); + }); + + it('정확하게 계산된 수익률과 평가 손익을 반환합니다', async () => { + // ISA 계좌가 존재하는 경우를 모킹, id는 mockUserId로 설정 + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: mockUserId, + }); + + // 월별 수익률 데이터 모킹, 특정 날짜와 수익률 0.12 (12%) 반환 + (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ + { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit: 0.12 }, + ]); + + // ETF 보유 스냅샷 데이터 모킹, 평가 금액 5,000,000원 반환 + (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ + { evaluatedAmount: 5000000, snapshotDate: new Date(), etfId: 1 }, + ]); + + // 일반 보유 스냅샷의 평가 금액 합계 모킹, 12,000,000원 반환 + (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ + _sum: { evaluatedAmount: 12000000 }, + }); + + // getMonthlyReturns 함수 호출 결과를 변수에 저장 + const result = await getMonthlyReturns('6'); + + // ETF와 일반 평가금액이 각각 정확히 반영되었는지 검증 + expect(result.evaluatedAmount).toBe(5000000 + 12000000); + expect(result.evaluatedProfit).toBe(0); + + // 반환된 결과가 기대한 수익률과 평가 금액, 평가 손익과 일치하는지 검증 + expect(result).toEqual({ + returns: [{ '2025-06-30': 12.0 }], + evaluatedAmount: 17000000, + evaluatedProfit: 0, + }); + }); +}); diff --git a/app/actions/get-monthly-returns.ts b/app/actions/get-monthly-returns.ts index 1926fd2..c140f5d 100644 --- a/app/actions/get-monthly-returns.ts +++ b/app/actions/get-monthly-returns.ts @@ -151,9 +151,10 @@ export async function getMonthlyReturns(month: MonthKey) { } catch (error: unknown) { if (error instanceof Error) { console.error('[getMonthlyReturns] Error:', error.message); + throw error; } else { console.error('[getMonthlyReturns] Unknown error:', error); + throw new Error('Failed to fetch monthly returns'); } - throw new Error('Failed to fetch monthly returns'); } } From 9de564759ae3f9c867f64da1786769654cf36616 Mon Sep 17 00:00:00 2001 From: jinlee Date: Fri, 27 Jun 2025 16:52:41 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20isa=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/services/get-isa-portfolio.test.ts | 121 +++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 __tests__/services/get-isa-portfolio.test.ts diff --git a/__tests__/services/get-isa-portfolio.test.ts b/__tests__/services/get-isa-portfolio.test.ts new file mode 100644 index 0000000..5f29564 --- /dev/null +++ b/__tests__/services/get-isa-portfolio.test.ts @@ -0,0 +1,121 @@ +import { getServerSession } from 'next-auth'; +import { getISAPortfolio } from '@/app/actions/get-isa-portfolio'; +import { prisma } from '@/lib/prisma'; + +// next-auth의 getServerSession 함수 모킹 +jest.mock('next-auth'); +// prisma 클라이언트의 iSAAccount 모델 모킹 +jest.mock('@/lib/prisma', () => ({ + prisma: { + iSAAccount: { + findUnique: jest.fn(), + }, + }, +})); + +describe('getISAPortfolio', () => { + // 각 테스트 전 모킹된 함수들의 호출 기록 초기화 + beforeEach(() => { + jest.clearAllMocks(); + }); + + // 세션이 없을 경우 'Unauthorized' 에러가 발생하는지 테스트 + it('throws if no session is found', async () => { + (getServerSession as jest.Mock).mockResolvedValue(null); + await expect(getISAPortfolio('2025-06')).rejects.toThrow('Unauthorized'); + }); + + // ISA 계좌에서 각 자산의 비율이 정확히 계산되는지 검증 + it('returns correct portfolio breakdown', async () => { + // 로그인된 유저 세션 모킹 + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: '1' }, + }); + + // prisma iSAAccount.findUnique 함수가 반환할 모킹 데이터 설정 + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + generalHoldingSnapshots: [], + generalHoldings: [ + { + totalCost: 1000000, + product: { instrumentType: 'BOND' }, + }, + { + totalCost: 2000000, + product: { instrumentType: 'FUND' }, + }, + { + totalCost: 3000000, + product: { instrumentType: 'ELS' }, + }, + ], + etfHoldingSnapshots: [ + { + evaluatedAmount: 4000000, + etf: { idxMarketType: '국내' }, + }, + { + evaluatedAmount: 5000000, + etf: { idxMarketType: '해외' }, + }, + { + evaluatedAmount: 6000000, + etf: { idxMarketType: '국내&해외' }, + }, + ], + }); + + // 실제 함수 호출 및 결과 저장 + const result = await getISAPortfolio('2025-06'); + + // 반환된 포트폴리오가 예상한 카테고리별 금액과 비율로 정확히 계산되었는지 검증 + expect(result).toEqual([ + { category: '채권', value: 1000000, percentage: 4.8 }, + { category: '펀드', value: 2000000, percentage: 9.5 }, + { category: 'ELS', value: 3000000, percentage: 14.3 }, + { category: '국내 ETF', value: 4000000, percentage: 19.0 }, + { category: '해외 ETF', value: 5000000, percentage: 23.8 }, + { category: '국내&해외 ETF', value: 6000000, percentage: 28.6 }, + ]); + }); + + // 반환된 포트폴리오의 전체 구조가 스냅샷과 일치하는지 확인 + it('matches snapshot for consistent portfolio structure', async () => { + (getServerSession as jest.Mock).mockResolvedValue({ user: { id: '1' } }); + + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + generalHoldingSnapshots: [], + generalHoldings: [ + { + totalCost: 1000000, + product: { instrumentType: 'BOND' }, + }, + { + totalCost: 2000000, + product: { instrumentType: 'FUND' }, + }, + { + totalCost: 3000000, + product: { instrumentType: 'ELS' }, + }, + ], + etfHoldingSnapshots: [ + { + evaluatedAmount: 4000000, + etf: { idxMarketType: '국내' }, + }, + { + evaluatedAmount: 5000000, + etf: { idxMarketType: '해외' }, + }, + { + evaluatedAmount: 6000000, + etf: { idxMarketType: '국내&해외' }, + }, + ], + }); + + const result = await getISAPortfolio('2025-06'); + expect(result).toMatchSnapshot(); + }); +}); From 43c0f4c0ded3ede585998f2660fd9091e6fc6a9e Mon Sep 17 00:00:00 2001 From: jinlee Date: Sun, 29 Jun 2025 17:07:12 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=EC=88=98=EB=A0=B9=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=88=98=EC=A0=95-=EC=88=98=EB=A0=B9?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=B6=94=EA=B0=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/challenge/claim/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/api/challenge/claim/route.ts b/app/api/challenge/claim/route.ts index 0926240..6fe74f3 100644 --- a/app/api/challenge/claim/route.ts +++ b/app/api/challenge/claim/route.ts @@ -30,11 +30,19 @@ export async function POST(req: Request) { }); //console.log('Challenge fetched:', challenge.id, challenge.challengeType); + // 보상 수령일: 오늘 자정 (UTC) + const todayStartOfKST = getTodayStartOfKST(); + const tomorrowStartOfKST = dayjs(todayStartOfKST).add(1, 'day').toDate(); + // 수령 여부 확인 const existingClaim = await prisma.userChallengeClaim.findFirst({ where: { userId, challengeId, + claimDate: { + gte: todayStartOfKST, + lt: tomorrowStartOfKST, + }, }, }); // console.log('Existing claim:', !!existingClaim); @@ -46,7 +54,7 @@ export async function POST(req: Request) { // 보상 수령일: 오늘 자정 (UTC) const now = new Date(); - const utcMidnight = getTodayStartOfKST(); + const utcMidnight = todayStartOfKST; const latestPrice = await prisma.etfDailyTrading.findFirst({ where: { etfId: challenge.etfId }, From 61ca31eadb1e73330720d42b3bafb039d7de3334 Mon Sep 17 00:00:00 2001 From: jinlee Date: Fri, 27 Jun 2025 23:41:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=9B=94=EB=B3=84=EC=88=98=EC=9D=B5?= =?UTF-8?q?=EB=A5=A0=20=ED=8F=89=EA=B0=80=EC=88=98=EC=9D=B5=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=88=98=EC=9D=B5=EB=A5=A0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=EC=88=98=EC=9D=B5=EB=A5=A0=20?= =?UTF-8?q?=EA=B3=B5=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/get-monthly-returns.test.ts | 146 +++++++++++++++--- 1 file changed, 121 insertions(+), 25 deletions(-) diff --git a/__tests__/services/get-monthly-returns.test.ts b/__tests__/services/get-monthly-returns.test.ts index 8681a74..e5a3147 100644 --- a/__tests__/services/get-monthly-returns.test.ts +++ b/__tests__/services/get-monthly-returns.test.ts @@ -51,39 +51,135 @@ describe('getMonthlyReturns', () => { ); }); - it('정확하게 계산된 수익률과 평가 손익을 반환합니다', async () => { - // ISA 계좌가 존재하는 경우를 모킹, id는 mockUserId로 설정 - (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ - id: mockUserId, + describe('정확한 평가금액 및 평가수익 계산', () => { + //isa 계좌 존재조건 모킹 + beforeEach(() => { + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: mockUserId, + }); }); - // 월별 수익률 데이터 모킹, 특정 날짜와 수익률 0.12 (12%) 반환 - (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ - { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit: 0.12 }, - ]); + it('ETF 500만원 + 일반 1200만원 → 총 1700만원, 평가수익 0원', async () => { + //수익률 모킹 + (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ + { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit: 0.12 }, + ]); + //ETF 스냅샷 평가금액 + (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ + { evaluatedAmount: 5000000, snapshotDate: new Date(), etfId: 1 }, + ]); + //general 평가금액 + (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ + _sum: { evaluatedAmount: 12000000 }, + }); - // ETF 보유 스냅샷 데이터 모킹, 평가 금액 5,000,000원 반환 - (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ - { evaluatedAmount: 5000000, snapshotDate: new Date(), etfId: 1 }, - ]); + //계산검증 + const res = await getMonthlyReturns('6'); - // 일반 보유 스냅샷의 평가 금액 합계 모킹, 12,000,000원 반환 - (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ - _sum: { evaluatedAmount: 12000000 }, + console.log('getMonthlyReturns 실행 결과:', res); + expect(prisma.iSAAccount.findUnique).toHaveBeenCalled(); + expect(prisma.monthlyReturn.findMany).toHaveBeenCalled(); + expect(prisma.eTFHoldingSnapshot.findMany).toHaveBeenCalled(); + expect(prisma.generalHoldingSnapshot.aggregate).toHaveBeenCalled(); + + const etf = 5000000; + const general = 12000000; + const total = etf + general; + const invested = 17000000; //투자원금 + const profit = total - invested; + + expect(res.evaluatedAmount).toBe(total); + expect(res.evaluatedProfit).toBe(profit); + expect(res).toEqual({ + returns: [{ '2025-06-30': 12.0 }], + evaluatedAmount: 17000000, + evaluatedProfit: 0, + }); + }); + + it('ETF 600만원 + 일반 1400만원 → 총 2000만원, 평가수익 300만원', async () => { + (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ + { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit: 0.15 }, + ]); + (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ + { evaluatedAmount: 6000000, snapshotDate: new Date(), etfId: 1 }, + ]); + (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ + _sum: { evaluatedAmount: 14000000 }, + }); + + const res = await getMonthlyReturns('6'); + + console.log('getMonthlyReturns 실행 결과:', res); + expect(prisma.iSAAccount.findUnique).toHaveBeenCalled(); + expect(prisma.monthlyReturn.findMany).toHaveBeenCalled(); + expect(prisma.eTFHoldingSnapshot.findMany).toHaveBeenCalled(); + expect(prisma.generalHoldingSnapshot.aggregate).toHaveBeenCalled(); + + const etf = 6000000; + const general = 14000000; + const total = etf + general; + const invested = 17000000; + const profit = total - invested; + + expect(res.evaluatedAmount).toBe(total); + expect(res.evaluatedProfit).toBe(profit); + expect(res).toEqual({ + returns: [{ '2025-06-30': 15.0 }], + evaluatedAmount: total, + evaluatedProfit: profit, + }); }); - // getMonthlyReturns 함수 호출 결과를 변수에 저장 - const result = await getMonthlyReturns('6'); + // 수익률 수식 테스트 + + // - E (평가금액), B (투자원금), C (중간 입금액) 값을 모킹 + // - expectedRate = (E - B - C) / (B + 0.5 * C) 계산 + // - entireProfit으로 모킹한 수익률이 계산 수식과 일치하는지 테스트 + // - returns 값이 수식에 따라 계산된 9.68%와 일치하는지 확인 + + it('수익률 공식 (E - B - C) / (B + 0.5 * C) 계산을 실제 테스트 코드에서 검증', async () => { + // ISA 계좌 mock + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: mockUserId, + }); + + // 수익률: (17.5M - 15M - 1M) / (15M + 0.5M) = 1.5M / 15.5M ≈ 0.0968 (9.68%) + const B = 15000000; + const C = 1000000; + const E = 17500000; + + const expectedRate = (E - B - C) / (B + 0.5 * C); + const expectedPercent = Number((expectedRate * 100).toFixed(2)); // 9.68 + + // 수익률 값 mocking + (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ + { + baseDate: new Date('2025-06-30T00:00:00Z'), + entireProfit: expectedRate, + }, + ]); + + // ETF 평가금액 E의 일부 + (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ + { evaluatedAmount: 10000000, snapshotDate: new Date(), etfId: 1 }, + ]); + + // 일반 + 현금 E의 일부 + (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ + _sum: { evaluatedAmount: 7500000 }, + }); + + const res = await getMonthlyReturns('6'); - // ETF와 일반 평가금액이 각각 정확히 반영되었는지 검증 - expect(result.evaluatedAmount).toBe(5000000 + 12000000); - expect(result.evaluatedProfit).toBe(0); + console.log('getMonthlyReturns 실행 결과:', res); + expect(prisma.iSAAccount.findUnique).toHaveBeenCalled(); + expect(prisma.monthlyReturn.findMany).toHaveBeenCalled(); + expect(prisma.eTFHoldingSnapshot.findMany).toHaveBeenCalled(); + expect(prisma.generalHoldingSnapshot.aggregate).toHaveBeenCalled(); - // 반환된 결과가 기대한 수익률과 평가 금액, 평가 손익과 일치하는지 검증 - expect(result).toEqual({ - returns: [{ '2025-06-30': 12.0 }], - evaluatedAmount: 17000000, - evaluatedProfit: 0, + expect(res.returns).toEqual([{ '2025-06-30': expectedPercent }]); + expect(res.evaluatedAmount).toBe(E); }); }); }); From 722de36a57f0c5b1ae63f7ed7edee1e1ffb7c7ad Mon Sep 17 00:00:00 2001 From: jinlee Date: Sat, 28 Jun 2025 00:07:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=88=98=EC=9D=B5=EB=A5=A0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=97=AC=ED=8D=BC=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80,=EA=B8=B0=EB=8C=80=EA=B0=92=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/get-monthly-returns.test.ts | 336 ++++++++++++------ 1 file changed, 218 insertions(+), 118 deletions(-) diff --git a/__tests__/services/get-monthly-returns.test.ts b/__tests__/services/get-monthly-returns.test.ts index e5a3147..80d0c0d 100644 --- a/__tests__/services/get-monthly-returns.test.ts +++ b/__tests__/services/get-monthly-returns.test.ts @@ -22,164 +22,264 @@ jest.mock('@/lib/prisma', () => ({ }, })); +// getMonthlyReturns 함수 테스트 +// 목적: 세션, ISA 계좌 여부, ETF 및 일반 자산 평가금액을 기반으로 총 평가금액과 수익률을 계산하는지 검증 + describe('getMonthlyReturns', () => { const mockUserId = 1; beforeEach(() => { - jest.clearAllMocks(); - // 로그인된 사용자 세션을 모킹, user.id를 문자열로 설정 + jest.clearAllMocks(); // 모든 mock 초기화 + // 기본 세션 및 ISA 계좌 모킹 함수 (getServerSession as jest.Mock).mockResolvedValue({ user: { id: mockUserId.toString() }, }); + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ + id: mockUserId, + }); }); - it('로그인하지 않은 경우 오류를 발생시킵니다', async () => { - // 로그인 세션이 없을 경우 null 반환하도록 모킹 - (getServerSession as jest.Mock).mockResolvedValueOnce(null); - // getMonthlyReturns 호출 시 "로그인이 필요합니다." 오류가 발생하는지 검증 - await expect(getMonthlyReturns('6')).rejects.toThrow( - '로그인이 필요합니다.' - ); - }); + // 헬퍼 함수: 기본 모킹 세팅 변경 + function setupMockData({ + session, + isaAccount, + monthlyReturns, + etfSnapshots, + generalSnapshot, + }: { + session?: any; + isaAccount?: any; + monthlyReturns?: any[]; + etfSnapshots?: any[]; + generalSnapshot?: any; + }) { + if (session !== undefined) { + (getServerSession as jest.Mock).mockResolvedValue(session); + } + if (isaAccount !== undefined) { + (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue(isaAccount); + } + if (monthlyReturns !== undefined) { + (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue( + monthlyReturns + ); + } + if (etfSnapshots !== undefined) { + (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue( + etfSnapshots + ); + } + if (generalSnapshot !== undefined) { + (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue( + generalSnapshot + ); + } + } - it('ISA 계좌가 없을 때 오류를 발생시킵니다', async () => { - // ISA 계좌 조회 시 null 반환하도록 모킹하여 계좌가 없음을 시뮬레이션 - (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue(null); - // getMonthlyReturns 호출 시 "ISA 계좌가 없습니다." 오류가 발생하는지 검증 - await expect(getMonthlyReturns('6')).rejects.toThrow( - 'ISA 계좌가 없습니다.' - ); - }); + // 헬퍼 함수: 월별 수익률 객체 생성 + function createMockMonthlyReturn(entireProfit: number) { + return { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit }; + } - describe('정확한 평가금액 및 평가수익 계산', () => { - //isa 계좌 존재조건 모킹 - beforeEach(() => { - (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ - id: mockUserId, - }); + // 헬퍼 함수: ETF 스냅샷 객체 생성 + function createMockETFSnapshot(evaluatedAmount: number, etfId = 1) { + return { evaluatedAmount, snapshotDate: new Date(), etfId }; + } + + // 헬퍼 함수: 일반 스냅샷 객체 생성 + function createMockGeneralSnapshot(evaluatedAmount: number) { + return { _sum: { evaluatedAmount } }; + } + + // 헬퍼 함수: 예상 값 계산 + function calculateExpectedValues(etfAmount: number, generalAmount: number) { + const totalAmount = etfAmount + generalAmount; + const invested = 17000000; // 고정 투자원금 + const profit = totalAmount - invested; + return { totalAmount, profit }; + } + + describe('에러 케이스', () => { + it('로그인하지 않은 경우 오류를 발생시킵니다', async () => { + // given + setupMockData({ session: null }); + + // when & then + await expect(getMonthlyReturns('6')).rejects.toThrow( + '로그인이 필요합니다.' + ); }); - it('ETF 500만원 + 일반 1200만원 → 총 1700만원, 평가수익 0원', async () => { - //수익률 모킹 - (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ - { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit: 0.12 }, - ]); - //ETF 스냅샷 평가금액 - (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ - { evaluatedAmount: 5000000, snapshotDate: new Date(), etfId: 1 }, - ]); - //general 평가금액 - (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ - _sum: { evaluatedAmount: 12000000 }, - }); + it('ISA 계좌가 없을 때 오류를 발생시킵니다', async () => { + // given + setupMockData({ isaAccount: null }); - //계산검증 - const res = await getMonthlyReturns('6'); + // when & then + await expect(getMonthlyReturns('6')).rejects.toThrow( + 'ISA 계좌가 없습니다.' + ); + }); + }); - console.log('getMonthlyReturns 실행 결과:', res); - expect(prisma.iSAAccount.findUnique).toHaveBeenCalled(); - expect(prisma.monthlyReturn.findMany).toHaveBeenCalled(); - expect(prisma.eTFHoldingSnapshot.findMany).toHaveBeenCalled(); - expect(prisma.generalHoldingSnapshot.aggregate).toHaveBeenCalled(); + describe('정확한 평가금액 및 평가수익 계산', () => { + it('ETF 500만원 + 일반 1200만원 → 총 1700만원, 평가수익 0원', async () => { + // given + const etfAmount = 5_000_000; + const generalAmount = 12_000_000; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); - const etf = 5000000; - const general = 12000000; - const total = etf + general; - const invested = 17000000; //투자원금 - const profit = total - invested; + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.12)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); - expect(res.evaluatedAmount).toBe(total); - expect(res.evaluatedProfit).toBe(profit); - expect(res).toEqual({ + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + expect(result).toEqual({ returns: [{ '2025-06-30': 12.0 }], - evaluatedAmount: 17000000, - evaluatedProfit: 0, + evaluatedAmount: totalAmount, + evaluatedProfit: profit, }); }); it('ETF 600만원 + 일반 1400만원 → 총 2000만원, 평가수익 300만원', async () => { - (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ - { baseDate: new Date('2025-06-30T00:00:00Z'), entireProfit: 0.15 }, - ]); - (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ - { evaluatedAmount: 6000000, snapshotDate: new Date(), etfId: 1 }, - ]); - (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ - _sum: { evaluatedAmount: 14000000 }, - }); + // given + const etfAmount = 6_000_000; + const generalAmount = 14_000_000; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); - const res = await getMonthlyReturns('6'); - - console.log('getMonthlyReturns 실행 결과:', res); - expect(prisma.iSAAccount.findUnique).toHaveBeenCalled(); - expect(prisma.monthlyReturn.findMany).toHaveBeenCalled(); - expect(prisma.eTFHoldingSnapshot.findMany).toHaveBeenCalled(); - expect(prisma.generalHoldingSnapshot.aggregate).toHaveBeenCalled(); + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.15)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); - const etf = 6000000; - const general = 14000000; - const total = etf + general; - const invested = 17000000; - const profit = total - invested; + // when + const result = await getMonthlyReturns('6'); - expect(res.evaluatedAmount).toBe(total); - expect(res.evaluatedProfit).toBe(profit); - expect(res).toEqual({ + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + expect(result).toEqual({ returns: [{ '2025-06-30': 15.0 }], - evaluatedAmount: total, + evaluatedAmount: totalAmount, evaluatedProfit: profit, }); }); + }); + + describe('수익률 공식 검증', () => { + it('수익률 공식 (E - B - C) / (B + 0.5 * C) 계산을 실제 테스트 코드에서 검증', async () => { + // given + const B = 15_000_000; + const C = 1_000_000; + const E = 17_500_000; - // 수익률 수식 테스트 + const expectedRate = (E - B - C) / (B + 0.5 * C); + const expectedPercent = Number((expectedRate * 100).toFixed(2)); // 9.68 - // - E (평가금액), B (투자원금), C (중간 입금액) 값을 모킹 - // - expectedRate = (E - B - C) / (B + 0.5 * C) 계산 - // - entireProfit으로 모킹한 수익률이 계산 수식과 일치하는지 테스트 - // - returns 값이 수식에 따라 계산된 9.68%와 일치하는지 확인 + const etfAmount = 10_000_000; + const generalAmount = 7_500_000; - it('수익률 공식 (E - B - C) / (B + 0.5 * C) 계산을 실제 테스트 코드에서 검증', async () => { - // ISA 계좌 mock - (prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({ - id: mockUserId, + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(expectedRate)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), }); - // 수익률: (17.5M - 15M - 1M) / (15M + 0.5M) = 1.5M / 15.5M ≈ 0.0968 (9.68%) - const B = 15000000; - const C = 1000000; - const E = 17500000; + // when + const result = await getMonthlyReturns('6'); - const expectedRate = (E - B - C) / (B + 0.5 * C); - const expectedPercent = Number((expectedRate * 100).toFixed(2)); // 9.68 + // then + expect(result.returns).toEqual([{ '2025-06-30': expectedPercent }]); + expect(result.evaluatedAmount).toBe(E); + }); + }); + + describe('다양한 시나리오 테스트', () => { + it('ETF만 있고 일반 자산이 없는 경우', async () => { + // given + const etfAmount = 8_000_000; + const generalAmount = 0; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); - // 수익률 값 mocking - (prisma.monthlyReturn.findMany as jest.Mock).mockResolvedValue([ - { - baseDate: new Date('2025-06-30T00:00:00Z'), - entireProfit: expectedRate, - }, - ]); - - // ETF 평가금액 E의 일부 - (prisma.eTFHoldingSnapshot.findMany as jest.Mock).mockResolvedValue([ - { evaluatedAmount: 10000000, snapshotDate: new Date(), etfId: 1 }, - ]); - - // 일반 + 현금 E의 일부 - (prisma.generalHoldingSnapshot.aggregate as jest.Mock).mockResolvedValue({ - _sum: { evaluatedAmount: 7500000 }, + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.1)], + etfSnapshots: [createMockETFSnapshot(etfAmount)], + generalSnapshot: createMockGeneralSnapshot(generalAmount), }); - const res = await getMonthlyReturns('6'); + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + }); + + it('일반 자산만 있고 ETF가 없는 경우', async () => { + // given + const etfAmount = 0; + const generalAmount = 9_000_000; + const { totalAmount, profit } = calculateExpectedValues( + etfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.08)], + etfSnapshots: [], + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); + + // when + const result = await getMonthlyReturns('6'); + + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); + }); + + it('여러 개의 ETF 스냅샷이 있는 경우', async () => { + // given + const etfSnapshots = [ + createMockETFSnapshot(3_000_000, 1), + createMockETFSnapshot(2_000_000, 2), + createMockETFSnapshot(1_500_000, 3), + ]; + const generalAmount = 10_500_000; + const totalEtfAmount = 6_500_000; + const { totalAmount, profit } = calculateExpectedValues( + totalEtfAmount, + generalAmount + ); + + setupMockData({ + monthlyReturns: [createMockMonthlyReturn(0.12)], + etfSnapshots, + generalSnapshot: createMockGeneralSnapshot(generalAmount), + }); - console.log('getMonthlyReturns 실행 결과:', res); - expect(prisma.iSAAccount.findUnique).toHaveBeenCalled(); - expect(prisma.monthlyReturn.findMany).toHaveBeenCalled(); - expect(prisma.eTFHoldingSnapshot.findMany).toHaveBeenCalled(); - expect(prisma.generalHoldingSnapshot.aggregate).toHaveBeenCalled(); + // when + const result = await getMonthlyReturns('6'); - expect(res.returns).toEqual([{ '2025-06-30': expectedPercent }]); - expect(res.evaluatedAmount).toBe(E); + // then + expect(result.evaluatedAmount).toBe(totalAmount); + expect(result.evaluatedProfit).toBe(profit); }); }); });