From 9e6264e274f111df93c0b3e1efeb5a5c14011a46 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Fri, 27 Jun 2025 16:37:42 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20kst=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=9E=90=EC=A0=95=20=EC=8B=9C=EA=B0=84=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/challenge/claim/route.ts | 11 +--------- services/challenge/streakChallengeProgress.ts | 11 +++------- utils/date.ts | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 utils/date.ts diff --git a/app/api/challenge/claim/route.ts b/app/api/challenge/claim/route.ts index 0926240..2346b04 100644 --- a/app/api/challenge/claim/route.ts +++ b/app/api/challenge/claim/route.ts @@ -1,19 +1,10 @@ import { getServerSession } from 'next-auth'; import { NextResponse } from 'next/server'; +import { getTodayStartOfKST } from '@/utils/date'; import { Prisma } from '@prisma/client'; -import dayjs from 'dayjs'; -import timezone from 'dayjs/plugin/timezone'; -import utc from 'dayjs/plugin/utc'; import { authOptions } from '@/lib/auth-options'; import { prisma } from '@/lib/prisma'; -dayjs.extend(utc); -dayjs.extend(timezone); - -function getTodayStartOfKST() { - return dayjs().tz('Asia/Seoul').startOf('day').utc().toDate(); -} - export async function POST(req: Request) { const session = await getServerSession(authOptions); if (!session?.user?.id) { diff --git a/services/challenge/streakChallengeProgress.ts b/services/challenge/streakChallengeProgress.ts index 91dfc4b..92fcf06 100644 --- a/services/challenge/streakChallengeProgress.ts +++ b/services/challenge/streakChallengeProgress.ts @@ -1,11 +1,6 @@ -import dayjs from 'dayjs'; -import timezone from 'dayjs/plugin/timezone'; -import utc from 'dayjs/plugin/utc'; +import { getTodayStartOfKST, getYesterdayStartOfKST } from '@/utils/date'; import { prisma } from '@/lib/prisma'; -dayjs.extend(utc); -dayjs.extend(timezone); - /** * STREAK 챌린지 진행도 업데이트 (7일 누적 출석 기준) */ @@ -13,8 +8,8 @@ export async function updateStreakProgress( userId: bigint, challengeId: bigint ) { - const todayStart = dayjs().tz('Asia/Seoul').startOf('day').toDate(); - const yesterdayStart = dayjs(todayStart).subtract(1, 'day').toDate(); + const todayStart = getTodayStartOfKST(); + const yesterdayStart = getYesterdayStartOfKST(); // 현재 진행 정보 const existingProgress = await prisma.userChallengeProgress.findUnique({ diff --git a/utils/date.ts b/utils/date.ts new file mode 100644 index 0000000..aad6554 --- /dev/null +++ b/utils/date.ts @@ -0,0 +1,20 @@ +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +/** + * KST 자정 (00:00) 기준의 Date 객체 반환 + */ +export function getTodayStartOfKST(): Date { + return dayjs().tz('Asia/Seoul').startOf('day').toDate(); +} + +/** + * 어제 KST 자정 기준 Date 객체 반환 + */ +export function getYesterdayStartOfKST(): Date { + return dayjs().tz('Asia/Seoul').startOf('day').subtract(1, 'day').toDate(); +} From cc97e8bc50f3203078aae24505f781ef7c7c526b Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 11:23:55 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=EC=B1=8C=EB=A6=B0=EC=A7=80?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B3=B5=ED=86=B5=ED=99=94=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/challenge/challenge-status.ts | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 services/challenge/challenge-status.ts diff --git a/services/challenge/challenge-status.ts b/services/challenge/challenge-status.ts new file mode 100644 index 0000000..015ee01 --- /dev/null +++ b/services/challenge/challenge-status.ts @@ -0,0 +1,131 @@ +import type { + Challenge, + UserChallengeClaim, + UserChallengeProgress, +} from '@prisma/client'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import type { PrismaTransaction } from '@/lib/prisma'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export type ChallengeStatus = 'CLAIMED' | 'ACHIEVABLE' | 'INCOMPLETE'; + +/* +export interface ChallengeWithProgress { + id: bigint + challengeType: string + userChallengeClaims: Array<{ claimDate: Date }> + userChallengeProgresses: Array<{ + progressVal: number + createdAt: Date + updatedAt: Date | null + }> +} +*/ + +export type ChallengeWithProgress = Pick & { + userChallengeClaims: Pick[]; + userChallengeProgresses: Pick< + UserChallengeProgress, + 'progressVal' | 'createdAt' | 'updatedAt' + >[]; +}; + +/** + * 챌린지 상태를 계산하는 공통 로직 + */ +export function calculateChallengeStatus( + challenge: ChallengeWithProgress, + userId: bigint +): ChallengeStatus { + const today = dayjs().tz('Asia/Seoul').startOf('day'); + + const hasClaim = challenge.userChallengeClaims.length > 0; + const progress = challenge.userChallengeProgresses[0]?.progressVal ?? 0; + const updatedAt = challenge.userChallengeProgresses[0]?.updatedAt; + const createdAt = challenge.userChallengeProgresses[0]?.createdAt; + + switch (challenge.challengeType) { + case 'ONCE': + return hasClaim ? 'CLAIMED' : progress >= 1 ? 'ACHIEVABLE' : 'INCOMPLETE'; + + case 'STREAK': { + const lastUpdated = updatedAt ? dayjs(updatedAt).tz('Asia/Seoul') : null; + // "7일 누적 연속 퀴즈 제출을 완료한 당일"만 허용 + const isStreakValid = lastUpdated?.isSame(today, 'day'); + + if (hasClaim) { + return 'CLAIMED'; + } else if (progress >= 7 && isStreakValid) { + return 'ACHIEVABLE'; + } else { + return 'INCOMPLETE'; + } + } + + case 'DAILY': { + const claimedToday = challenge.userChallengeClaims.some((cl) => + today.isSame(dayjs(cl.claimDate).tz('Asia/Seoul'), 'day') + ); + + const updatedAtDay = updatedAt ? dayjs(updatedAt).tz('Asia/Seoul') : null; + const createdAtDay = createdAt ? dayjs(createdAt).tz('Asia/Seoul') : null; + + const isUpdatedToday = updatedAtDay?.isSame(today, 'day'); + const isCreatedToday = createdAtDay?.isSame(today, 'day'); + + if (claimedToday) { + return 'CLAIMED'; + } else if (progress > 0 && (isUpdatedToday || isCreatedToday)) { + return 'ACHIEVABLE'; + } else { + return 'INCOMPLETE'; + } + } + + default: + return 'INCOMPLETE'; + } +} + +/** + * 챌린지 보상 수령 가능 여부 확인 + */ +export async function canClaimChallenge( + challengeId: bigint, + userId: bigint, + tx: PrismaTransaction +): Promise<{ canClaim: boolean; reason?: string }> { + const challenge = await tx.challenge.findUnique({ + where: { id: challengeId }, + include: { + userChallengeClaims: { + where: { userId }, + select: { claimDate: true }, + }, + userChallengeProgresses: { + where: { userId }, + select: { progressVal: true, createdAt: true, updatedAt: true }, + }, + }, + }); + + if (!challenge) { + return { canClaim: false, reason: 'Challenge not found' }; + } + + const status = calculateChallengeStatus(challenge, userId); + + if (status === 'CLAIMED') { + return { canClaim: false, reason: 'Already claimed' }; + } + + if (status !== 'ACHIEVABLE') { + return { canClaim: false, reason: 'Challenge not completed' }; + } + + return { canClaim: true }; +} From d51fbe0b3a3fbaa08b23bcb4990ce3d3caca9290 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 11:57:51 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EB=A0=B9=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EB=A0=B9=20=EA=B2=80=EC=A6=9D=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/challenge/challenge-claim.ts | 161 ++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 services/challenge/challenge-claim.ts diff --git a/services/challenge/challenge-claim.ts b/services/challenge/challenge-claim.ts new file mode 100644 index 0000000..8a23059 --- /dev/null +++ b/services/challenge/challenge-claim.ts @@ -0,0 +1,161 @@ +import { getTodayStartOfKST } from '@/utils/date'; +import type { Prisma } from '@prisma/client'; +import type { PrismaTransaction } from '@/lib/prisma'; +import { calculateChallengeStatus } from './challenge-status'; + +export type ClaimChallengeParams = { + challengeId: bigint; + userId: bigint; +}; + +export type ClaimChallengeResult = { + success: boolean; + message: string; + transactionId?: bigint; +}; + +/** + * 챌린지 보상 수령 비즈니스 로직 + */ +export async function claimChallengeReward( + params: ClaimChallengeParams, + tx: PrismaTransaction +): Promise { + const { challengeId, userId } = params; + + // 1. 챌린지 정보 조회 (상태 판단에 필요한 데이터 포함) + const challenge = await tx.challenge.findUnique({ + where: { id: challengeId }, + include: { + etf: true, + userChallengeClaims: { + where: { userId }, + select: { claimDate: true }, + }, + userChallengeProgresses: { + where: { userId }, + select: { progressVal: true, createdAt: true, updatedAt: true }, + }, + }, + }); + + if (!challenge) { + return { success: false, message: 'Challenge not found' }; + } + + // 2. 챌린지 상태 확인 (calculateChallengeStatus 사용) + const challengeStatus = calculateChallengeStatus(challenge, userId); + + if (challengeStatus === 'CLAIMED') { + return { success: false, message: 'Already claimed' }; + } + + if (challengeStatus !== 'ACHIEVABLE') { + return { success: false, message: 'Challenge not completed' }; + } + + // 3. 사용자 ISA 계좌 확인 + const user = await tx.user.findUnique({ + where: { id: userId }, + include: { isaAccount: true }, + }); + + if (!user?.isaAccount?.id) { + return { success: false, message: 'ISA account not found' }; + } + + const isaAccountId = user.isaAccount.id; + + // 4. 최신 ETF 종가 조회 + const latestTrading = await tx.etfDailyTrading.findFirst({ + where: { etfId: challenge.etfId }, + orderBy: { baseDate: 'desc' }, + }); + + if (!latestTrading?.tddClosePrice) { + return { success: false, message: 'Latest ETF price not found' }; + } + + const now = new Date(); + const utcMidnight = getTodayStartOfKST(); + + // 5. 보상 수령 처리 + // 5-1. 수령 기록 저장 + await tx.userChallengeClaim.create({ + data: { + userId, + challengeId, + claimDate: utcMidnight, + }, + }); + + // 5-2. 진행도 초기화 (ONCE 타입 제외) + if (challenge.challengeType !== 'ONCE') { + await tx.userChallengeProgress.updateMany({ + where: { userId, challengeId }, + data: { progressVal: 0 }, + }); + } + + // 5-3. ETF 거래 기록 생성 + const transaction = await tx.eTFTransaction.create({ + data: { + isaAccountId, + etfId: challenge.etfId, + quantity: challenge.quantity, + transactionType: 'CHALLENGE_REWARD', + price: latestTrading.tddClosePrice, + transactionAt: now, + }, + }); + + // 5-4. ETF 보유량 업데이트 + const existingHolding = await tx.eTFHolding.findUnique({ + where: { + isaAccountId_etfId: { + isaAccountId, + etfId: challenge.etfId, + }, + }, + }); + + let avgCost: Prisma.Decimal; + + if (existingHolding) { + const totalQuantity = existingHolding.quantity.add(challenge.quantity); + const totalCost = existingHolding.avgCost + .mul(existingHolding.quantity) + .add(challenge.quantity.mul(latestTrading.tddClosePrice)); + avgCost = totalCost.div(totalQuantity); + } else { + avgCost = latestTrading.tddClosePrice; + } + + await tx.eTFHolding.upsert({ + where: { + isaAccountId_etfId: { + isaAccountId, + etfId: challenge.etfId, + }, + }, + update: { + quantity: { increment: challenge.quantity }, + avgCost: avgCost, + updatedAt: now, + }, + create: { + isaAccountId, + etfId: challenge.etfId, + quantity: challenge.quantity, + avgCost: avgCost, + acquiredAt: now, + updatedAt: now, + }, + }); + + return { + success: true, + message: 'Reward claimed successfully', + transactionId: transaction.id, + }; +} From 265907e5329eaac9890da1ec2b177586424d3ab9 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 12:11:27 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EB=B6=84=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EB=A0=B9=20API=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= =?UTF-8?q?=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/challenge/claim/route.ts | 170 ++++++-------------------- services/challenge/challenge-claim.ts | 40 ++---- 2 files changed, 48 insertions(+), 162 deletions(-) diff --git a/app/api/challenge/claim/route.ts b/app/api/challenge/claim/route.ts index 2346b04..9a024ee 100644 --- a/app/api/challenge/claim/route.ts +++ b/app/api/challenge/claim/route.ts @@ -1,7 +1,7 @@ import { getServerSession } from 'next-auth'; import { NextResponse } from 'next/server'; -import { getTodayStartOfKST } from '@/utils/date'; -import { Prisma } from '@prisma/client'; +import { claimChallengeReward } from '@/services/challenge/challenge-claim'; +import { canClaimChallenge } from '@/services/challenge/challenge-status'; import { authOptions } from '@/lib/auth-options'; import { prisma } from '@/lib/prisma'; @@ -14,143 +14,51 @@ export async function POST(req: Request) { const userId = BigInt(session.user.id); const { challengeId } = await req.json(); - // 챌린지 정보 불러오기 (유형 포함) - const challenge = await prisma.challenge.findUniqueOrThrow({ - where: { id: challengeId }, - include: { etf: true }, - }); - //console.log('Challenge fetched:', challenge.id, challenge.challengeType); - - // 수령 여부 확인 - const existingClaim = await prisma.userChallengeClaim.findFirst({ - where: { - userId, - challengeId, - }, - }); - // console.log('Existing claim:', !!existingClaim); - - //이미 받았음 - if (existingClaim) { - return NextResponse.json({ message: 'Already claimed' }, { status: 400 }); + if (!challengeId) { + return NextResponse.json( + { message: 'Challenge ID is required' }, + { status: 400 } + ); } - // 보상 수령일: 오늘 자정 (UTC) - const now = new Date(); - const utcMidnight = getTodayStartOfKST(); + const challengeIdBigInt = BigInt(challengeId); - const latestPrice = await prisma.etfDailyTrading.findFirst({ - where: { etfId: challenge.etfId }, - orderBy: { baseDate: 'desc' }, - select: { tddClosePrice: true }, - }); - - if (latestPrice?.tddClosePrice) { - //const expectedCost = challenge.quantity.mul(latestPrice.tddClosePrice) - //console.log('✅ 검증용 expected avgCost:', expectedCost.toFixed(2)) - } - - //트랜잭션 처리 - await prisma.$transaction(async (tx) => { - // 1. 수령 기록 저장 - await tx.userChallengeClaim.create({ - data: { + try { + const result = await prisma.$transaction(async (tx) => { + // 1. 보상 수령 가능 여부 확인 + const { canClaim, reason } = await canClaimChallenge( + challengeIdBigInt, userId, - challengeId, - claimDate: utcMidnight, - }, - }); - //console.log(' 📍Claim record created for user:', userId.toString(), 'challenge:', challengeId.toString()); - - // 2. 진행도 초기화 - if (challenge.challengeType !== 'ONCE') { - await tx.userChallengeProgress.updateMany({ - where: { userId, challengeId }, - data: { progressVal: 0 }, - }); - //console.log('Progress reset for user:', userId.toString(), 'challenge:', challengeId.toString()); - } - - // 3. 보상 지급 처리 - const user = await tx.user.findUniqueOrThrow({ - where: { id: userId }, - include: { isaAccount: true }, - }); - const isaAccountId = user.isaAccount?.id; - //console.log('📍User fetched with ISA account:', isaAccountId?.toString()); - - if (!isaAccountId) throw new Error('ISA 계좌가 없습니다'); - - // ETF daily trading 에서 가장 최신 종가 - const latestTrading = await tx.etfDailyTrading.findFirst({ - where: { etfId: challenge.etfId }, - orderBy: { baseDate: 'desc' }, + tx + ); + + if (!canClaim) { + throw new Error(reason || 'Cannot claim reward'); + } + + // 2. 보상 수령 처리 + return await claimChallengeReward( + { challengeId: challengeIdBigInt, userId }, + tx + ); }); - //console.log("최신종가 : ", latestTrading); - if (!latestTrading?.tddClosePrice) { - throw new Error('최신 종가 정보를 찾을 수 없습니다'); + if (!result.success) { + return NextResponse.json({ message: result.message }, { status: 400 }); } - const transaction = await tx.eTFTransaction.create({ - data: { - isaAccountId, - etfId: challenge.etfId, - quantity: challenge.quantity, - transactionType: 'CHALLENGE_REWARD', - price: latestTrading.tddClosePrice, - transactionAt: now, - }, + return NextResponse.json({ + message: result.message, + transactionId: result.transactionId?.toString(), }); - //console.log('Transaction created:', transaction); - - //최신종가 * 지급수량 - const existingHolding = await tx.eTFHolding.findUnique({ - where: { - isaAccountId_etfId: { - isaAccountId, - etfId: challenge.etfId, - }, + } catch (error) { + console.error('Challenge claim error:', error); + return NextResponse.json( + { + message: + error instanceof Error ? error.message : 'Internal server error', }, - }); - - let avgCost: Prisma.Decimal; - - if (existingHolding) { - const totalQuantity = existingHolding.quantity.add(challenge.quantity); - const totalCost = existingHolding.avgCost - .mul(existingHolding.quantity) - .add(challenge.quantity.mul(latestTrading.tddClosePrice)); - avgCost = totalCost.div(totalQuantity); - //console.log('📌 Adjusted avgCost for existing holding:', avgCost.toFixed(2)) - } else { - avgCost = latestTrading.tddClosePrice; - //console.log('📌 New holding avgCost (latest price):', avgCost.toFixed(2)) - } - - await tx.eTFHolding.upsert({ - where: { - isaAccountId_etfId: { - isaAccountId, - etfId: challenge.etfId, - }, - }, - update: { - quantity: { increment: challenge.quantity }, - avgCost: avgCost, - updatedAt: now, - }, - create: { - isaAccountId, - etfId: challenge.etfId, - quantity: challenge.quantity, - avgCost: avgCost, - acquiredAt: now, - updatedAt: now, - }, - }); - //console.log('ETF holding updated or created'); - }); - - return NextResponse.json({ message: 'Reward claimed successfully' }); + { status: 500 } + ); + } } diff --git a/services/challenge/challenge-claim.ts b/services/challenge/challenge-claim.ts index 8a23059..30c99c3 100644 --- a/services/challenge/challenge-claim.ts +++ b/services/challenge/challenge-claim.ts @@ -1,7 +1,6 @@ import { getTodayStartOfKST } from '@/utils/date'; import type { Prisma } from '@prisma/client'; import type { PrismaTransaction } from '@/lib/prisma'; -import { calculateChallengeStatus } from './challenge-status'; export type ClaimChallengeParams = { challengeId: bigint; @@ -23,38 +22,17 @@ export async function claimChallengeReward( ): Promise { const { challengeId, userId } = params; - // 1. 챌린지 정보 조회 (상태 판단에 필요한 데이터 포함) + // 1. 챌린지 정보 조회 const challenge = await tx.challenge.findUnique({ where: { id: challengeId }, - include: { - etf: true, - userChallengeClaims: { - where: { userId }, - select: { claimDate: true }, - }, - userChallengeProgresses: { - where: { userId }, - select: { progressVal: true, createdAt: true, updatedAt: true }, - }, - }, + include: { etf: true }, }); if (!challenge) { return { success: false, message: 'Challenge not found' }; } - // 2. 챌린지 상태 확인 (calculateChallengeStatus 사용) - const challengeStatus = calculateChallengeStatus(challenge, userId); - - if (challengeStatus === 'CLAIMED') { - return { success: false, message: 'Already claimed' }; - } - - if (challengeStatus !== 'ACHIEVABLE') { - return { success: false, message: 'Challenge not completed' }; - } - - // 3. 사용자 ISA 계좌 확인 + // 2. 사용자 ISA 계좌 확인 const user = await tx.user.findUnique({ where: { id: userId }, include: { isaAccount: true }, @@ -66,7 +44,7 @@ export async function claimChallengeReward( const isaAccountId = user.isaAccount.id; - // 4. 최신 ETF 종가 조회 + // 3. 최신 ETF 종가 조회 const latestTrading = await tx.etfDailyTrading.findFirst({ where: { etfId: challenge.etfId }, orderBy: { baseDate: 'desc' }, @@ -79,8 +57,8 @@ export async function claimChallengeReward( const now = new Date(); const utcMidnight = getTodayStartOfKST(); - // 5. 보상 수령 처리 - // 5-1. 수령 기록 저장 + // 4. 보상 수령 처리 + // 4-1. 수령 기록 저장 await tx.userChallengeClaim.create({ data: { userId, @@ -89,7 +67,7 @@ export async function claimChallengeReward( }, }); - // 5-2. 진행도 초기화 (ONCE 타입 제외) + // 4-2. 진행도 초기화 (ONCE 타입 제외) if (challenge.challengeType !== 'ONCE') { await tx.userChallengeProgress.updateMany({ where: { userId, challengeId }, @@ -97,7 +75,7 @@ export async function claimChallengeReward( }); } - // 5-3. ETF 거래 기록 생성 + // 4-3. ETF 거래 기록 생성 const transaction = await tx.eTFTransaction.create({ data: { isaAccountId, @@ -109,7 +87,7 @@ export async function claimChallengeReward( }, }); - // 5-4. ETF 보유량 업데이트 + // 4-4. ETF 보유량 업데이트 const existingHolding = await tx.eTFHolding.findUnique({ where: { isaAccountId_etfId: { From 2e3f48aada5e60e8a7bba1e2ace5a6d03b2db508 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 15:10:12 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20getChallenges=EC=97=90=20calc?= =?UTF-8?q?ulateChallengeStatus=20=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/get-challenge.ts | 73 +++--------------------------------- 1 file changed, 6 insertions(+), 67 deletions(-) diff --git a/app/actions/get-challenge.ts b/app/actions/get-challenge.ts index b30b541..fcffdc3 100644 --- a/app/actions/get-challenge.ts +++ b/app/actions/get-challenge.ts @@ -1,16 +1,14 @@ 'use server'; import { getServerSession } from 'next-auth'; -import dayjs from 'dayjs'; -import timezone from 'dayjs/plugin/timezone'; -import utc from 'dayjs/plugin/utc'; +import { + calculateChallengeStatus, + type ChallengeStatus, +} from '@/services/challenge/challenge-status'; import { authOptions } from '@/lib/auth-options'; import { prisma } from '@/lib/prisma'; -dayjs.extend(utc); -dayjs.extend(timezone); - -export type ChallengeStatus = 'CLAIMED' | 'ACHIEVABLE' | 'INCOMPLETE'; +export type { ChallengeStatus }; export type ChallengeInfo = { id: string; @@ -26,9 +24,6 @@ export async function getChallenges(): Promise { if (!session?.user?.id) throw new Error('Not authenticated'); const userId = BigInt(session.user.id); - // KST 자정 기준 today - const today = dayjs().tz('Asia/Seoul').startOf('day'); - const rows = await prisma.challenge.findMany({ include: { etf: { select: { issueName: true } }, @@ -44,63 +39,7 @@ export async function getChallenges(): Promise { }); return rows.map((c) => { - const hasClaim = c.userChallengeClaims.length > 0; - const progress = c.userChallengeProgresses[0]?.progressVal ?? 0; - const updatedAt = c.userChallengeProgresses[0]?.updatedAt; - const createdAt = c.userChallengeProgresses[0]?.createdAt; - - let status: ChallengeStatus; - switch (c.challengeType) { - case 'ONCE': - status = hasClaim - ? 'CLAIMED' - : progress >= 1 - ? 'ACHIEVABLE' - : 'INCOMPLETE'; - break; - case 'STREAK': { - const lastUpdated = updatedAt - ? dayjs(updatedAt).tz('Asia/Seoul') - : null; - // "7일 누적 연속 퀴즈 제출을 완료한 당일"만 허용 yesterday -> today - const isStreakValid = lastUpdated?.isSame(today, 'day'); - - if (hasClaim) { - status = 'CLAIMED'; - } else if (progress >= 7 && isStreakValid) { - status = 'ACHIEVABLE'; - } else { - status = 'INCOMPLETE'; - } - break; - } - case 'DAILY': { - const claimedToday = c.userChallengeClaims.some((cl) => - today.isSame(dayjs(cl.claimDate).tz('Asia/Seoul'), 'day') - ); - - const updatedAtDay = updatedAt - ? dayjs(updatedAt).tz('Asia/Seoul') - : null; - const createdAtDay = createdAt - ? dayjs(createdAt).tz('Asia/Seoul') - : null; - - const isUpdatedToday = updatedAtDay?.isSame(today, 'day'); - const isCreatedToday = createdAtDay?.isSame(today, 'day'); - - if (claimedToday) { - status = 'CLAIMED'; - } else if (progress > 0 && (isUpdatedToday || isCreatedToday)) { - status = 'ACHIEVABLE'; - } else { - status = 'INCOMPLETE'; - } - break; - } - default: - status = 'INCOMPLETE'; - } + const status = calculateChallengeStatus(c, userId); return { id: c.id.toString(), From 81bd0a01c981cec07f530ac121e3a3c2f5796788 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 15:28:01 +0900 Subject: [PATCH 06/10] =?UTF-8?q?test:=20challenge=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20prisma=20mock=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __mocks__/prisma-factory.ts | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/__mocks__/prisma-factory.ts b/__mocks__/prisma-factory.ts index 799034c..57f0413 100644 --- a/__mocks__/prisma-factory.ts +++ b/__mocks__/prisma-factory.ts @@ -52,3 +52,88 @@ export const createEtfTestPrismaMock = (overrides = {}) => { ...overrides, }; }; + +export const createChallengePrismaMock = (overrides = {}) => { + const baseMock = { + user: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + challenge: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + userChallengeClaim: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + userChallengeProgress: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + upsert: jest.fn(), + }, + etf: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + etfDailyTrading: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + eTFTransaction: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + eTFHolding: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + isaAccount: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }; + + return { + ...baseMock, + ...overrides, + }; +}; + +// 타입 정의 +export type BasePrismaMock = ReturnType; +export type EtfTestPrismaMock = ReturnType; +export type ChallengePrismaMock = ReturnType; +export type PrismaMockInstance = + | BasePrismaMock + | EtfTestPrismaMock + | ChallengePrismaMock; From cddbecbcc29a6dfeea7588da76037e29bd5dfe87 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 15:54:37 +0900 Subject: [PATCH 07/10] =?UTF-8?q?test:=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EB=B3=B4=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EB=A0=B9=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __mocks__/prisma.ts | 11 +- __tests__/services/challenge-status.test.ts | 253 ++++++++++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 __tests__/services/challenge-status.test.ts diff --git a/__mocks__/prisma.ts b/__mocks__/prisma.ts index 341ab97..cf5ce74 100644 --- a/__mocks__/prisma.ts +++ b/__mocks__/prisma.ts @@ -1,4 +1,8 @@ -import { createEtfTestPrismaMock, createPrismaMock } from './prisma-factory'; +import { + createChallengePrismaMock, + createEtfTestPrismaMock, + createPrismaMock, +} from './prisma-factory'; // Jest에서 사용할 수 있도록 전역 mock 인스턴스 생성 let mockPrismaInstance = createPrismaMock(); @@ -17,6 +21,11 @@ export const resetWithEtfTestPrismaMock = () => { return mockPrismaInstance; }; +export const resetWithChallengePrismaMock = () => { + mockPrismaInstance = createChallengePrismaMock(); + return mockPrismaInstance; +}; + // 커스텀 overrides로 재설정 export const resetWithCustomMock = (overrides = {}) => { mockPrismaInstance = createPrismaMock(overrides); diff --git a/__tests__/services/challenge-status.test.ts b/__tests__/services/challenge-status.test.ts new file mode 100644 index 0000000..aacd7e9 --- /dev/null +++ b/__tests__/services/challenge-status.test.ts @@ -0,0 +1,253 @@ +import { createChallengePrismaMock } from '@/__mocks__/prisma-factory'; +import { + calculateChallengeStatus, + canClaimChallenge, + type ChallengeWithProgress, +} from '@/services/challenge/challenge-status'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +describe('Challenge Status Service', () => { + const userId = BigInt(1); + const today = dayjs().tz('Asia/Seoul').startOf('day'); + + describe('calculateChallengeStatus', () => { + it('should return CLAIMED when challenge is already claimed', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [{ claimDate: new Date() }], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('CLAIMED'); + }); + + it('should return ACHIEVABLE for ONCE type when progress >= 1', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('should return INCOMPLETE for ONCE type when progress < 1', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 0, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + + it('should return ACHIEVABLE for STREAK type when progress >= 7 and updated today', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'STREAK', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 7, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('should return INCOMPLETE for STREAK type when progress >= 7 but not updated today', () => { + const yesterday = today.subtract(1, 'day'); + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'STREAK', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 7, + createdAt: new Date(), + updatedAt: yesterday.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + + it('should return ACHIEVABLE for DAILY type when progress > 0 and updated today', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 1, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('should return ACHIEVABLE for DAILY type when progress > 0 and created today', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 1, + createdAt: today.toDate(), + updatedAt: null, + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('ACHIEVABLE'); + }); + + it('should return CLAIMED for DAILY type when already claimed today', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [{ claimDate: today.toDate() }], + userChallengeProgresses: [ + { + progressVal: 1, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('CLAIMED'); + }); + + it('should return INCOMPLETE for DAILY type when progress = 0 even if updated today', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'DAILY', + userChallengeClaims: [], + userChallengeProgresses: [ + { + progressVal: 0, + createdAt: new Date(), + updatedAt: today.toDate(), + }, + ], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + + it('should return INCOMPLETE if no progress data exists', () => { + const challenge: ChallengeWithProgress = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [], + }; + + const status = calculateChallengeStatus(challenge, userId); + expect(status).toBe('INCOMPLETE'); + }); + }); + + describe('canClaimChallenge', () => { + let mockTx: any; + + beforeEach(() => { + mockTx = createChallengePrismaMock(); + }); + + it('should return false when challenge not found', async () => { + mockTx.challenge.findUnique.mockResolvedValue(null); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(false); + expect(result.reason).toBe('Challenge not found'); + }); + + it('should return false when already claimed', async () => { + const mockChallenge = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [{ claimDate: new Date() }], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(false); + expect(result.reason).toBe('Already claimed'); + }); + + it('should return true when challenge is achievable', async () => { + const mockChallenge = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 1, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('should return false when challenge is not completed', async () => { + const mockChallenge = { + id: BigInt(1), + challengeType: 'ONCE', + userChallengeClaims: [], + userChallengeProgresses: [ + { progressVal: 0, createdAt: new Date(), updatedAt: new Date() }, + ], + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + + const result = await canClaimChallenge(BigInt(1), userId, mockTx); + + expect(result.canClaim).toBe(false); + expect(result.reason).toBe('Challenge not completed'); + }); + }); +}); From 2db65c3609de6f9216fc4f01d4291e36fec80c19 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 16:23:25 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test:=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=88=98=EB=A0=B9=20=EC=8B=9C=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __mocks__/prisma-factory.ts | 9 + __tests__/services/challenge-claim.test.ts | 354 +++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 __tests__/services/challenge-claim.test.ts diff --git a/__mocks__/prisma-factory.ts b/__mocks__/prisma-factory.ts index 57f0413..1bfb219 100644 --- a/__mocks__/prisma-factory.ts +++ b/__mocks__/prisma-factory.ts @@ -2,6 +2,9 @@ type MockFn = { [P in keyof T]: jest.Mock; }; +/** + * 기본 Prisma Mock 생성 + */ export const createPrismaMock = (overrides = {}) => { const baseMock = { user: { @@ -16,6 +19,9 @@ export const createPrismaMock = (overrides = {}) => { }; }; +/** + * ETF 테스트용 Prisma Mock 생성 + */ export const createEtfTestPrismaMock = (overrides = {}) => { const baseMock = { user: { @@ -53,6 +59,9 @@ export const createEtfTestPrismaMock = (overrides = {}) => { }; }; +/** + * 챌린지 테스트용 Prisma Mock 생성 + */ export const createChallengePrismaMock = (overrides = {}) => { const baseMock = { user: { diff --git a/__tests__/services/challenge-claim.test.ts b/__tests__/services/challenge-claim.test.ts new file mode 100644 index 0000000..36ea1d5 --- /dev/null +++ b/__tests__/services/challenge-claim.test.ts @@ -0,0 +1,354 @@ +import { createChallengePrismaMock } from '@/__mocks__/prisma-factory'; +import { claimChallengeReward } from '@/services/challenge/challenge-claim'; +import { Prisma } from '@prisma/client'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +describe('Challenge Claim Service', () => { + let mockTx: any; + const userId = BigInt(1); + const challengeId = BigInt(1); + const etfId = BigInt(1); + const isaAccountId = BigInt(1); + const today = dayjs().tz('Asia/Seoul').startOf('day'); + + beforeEach(() => { + mockTx = createChallengePrismaMock(); + }); + + it('should successfully claim ONCE type challenge reward', async () => { + // Arrange + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(10), + challengeType: 'ONCE', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal(1000), + }; + + const mockTransaction = { + id: BigInt(1), + isaAccountId, + etfId, + quantity: new Prisma.Decimal(10), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(1000), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(null); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + // Act + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + // Assert + expect(result.success).toBe(true); + expect(result.message).toBe('Reward claimed successfully'); + expect(result.transactionId).toBe(BigInt(1)); + + expect(mockTx.userChallengeClaim.create).toHaveBeenCalledWith({ + data: { + userId, + challengeId, + claimDate: expect.any(Date), + }, + }); + + expect(mockTx.eTFTransaction.create).toHaveBeenCalledWith({ + data: { + isaAccountId, + etfId, + quantity: new Prisma.Decimal(10), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(1000), + transactionAt: expect.any(Date), + }, + }); + + // ONCE 타입은 진행도 초기화하지 않음 + expect(mockTx.userChallengeProgress.updateMany).not.toHaveBeenCalled(); + }); + + it('should successfully claim DAILY type challenge reward and reset progress', async () => { + // Arrange + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(5), + challengeType: 'DAILY', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal(2000), + }; + + const mockTransaction = { + id: BigInt(2), + isaAccountId, + etfId, + quantity: new Prisma.Decimal(5), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(2000), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(null); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + // Act + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + // Assert + expect(result.success).toBe(true); + expect(result.message).toBe('Reward claimed successfully'); + expect(result.transactionId).toBe(BigInt(2)); + + // DAILY 타입은 진행도 초기화 + expect(mockTx.userChallengeProgress.updateMany).toHaveBeenCalledWith({ + where: { userId, challengeId }, + data: { progressVal: 0 }, + }); + }); + + it('should return error when challenge not found', async () => { + mockTx.challenge.findUnique.mockResolvedValue(null); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(false); + expect(result.message).toBe('Challenge not found'); + }); + + it('should return error when ISA account not found', async () => { + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(10), + challengeType: 'ONCE', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: null, + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(false); + expect(result.message).toBe('ISA account not found'); + }); + + it('should return error when latest ETF price not found', async () => { + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(10), + challengeType: 'ONCE', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(null); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(false); + expect(result.message).toBe('Latest ETF price not found'); + }); + + it('should handle existing ETF holding correctly', async () => { + // Arrange + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal(5), + challengeType: 'DAILY', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal(2000), + }; + + const mockExistingHolding = { + quantity: new Prisma.Decimal(10), + avgCost: new Prisma.Decimal(1500), + }; + + const mockTransaction = { + id: BigInt(2), + isaAccountId, + etfId, + quantity: new Prisma.Decimal(5), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal(2000), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(mockExistingHolding); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + // Act + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + // Assert + expect(result.success).toBe(true); + expect(mockTx.userChallengeProgress.updateMany).toHaveBeenCalledWith({ + where: { userId, challengeId }, + data: { progressVal: 0 }, + }); + + // 평균 단가 계산 검증: (10 * 1500 + 5 * 2000) / 15 = 1666.67 + const expectedAvgCost = new Prisma.Decimal(15000) + .add(new Prisma.Decimal(10000)) + .div(new Prisma.Decimal(15)); + + expect(mockTx.eTFHolding.upsert).toHaveBeenCalledWith({ + where: { + isaAccountId_etfId: { + isaAccountId, + etfId, + }, + }, + update: { + quantity: { increment: new Prisma.Decimal(5) }, + avgCost: expectedAvgCost, + updatedAt: expect.any(Date), + }, + create: { + isaAccountId, + etfId, + quantity: new Prisma.Decimal(5), + avgCost: expectedAvgCost, + acquiredAt: expect.any(Date), + updatedAt: expect.any(Date), + }, + }); + }); + + it('should correctly calculate avgCost when quantity and price have decimals', async () => { + const mockChallenge = { + id: challengeId, + etfId, + quantity: new Prisma.Decimal('2.5'), + challengeType: 'DAILY', + etf: { id: etfId, issueName: 'Test ETF' }, + }; + + const mockUser = { + id: userId, + isaAccount: { id: isaAccountId }, + }; + + const mockLatestTrading = { + tddClosePrice: new Prisma.Decimal('1500.75'), + }; + + const mockExistingHolding = { + quantity: new Prisma.Decimal('3.5'), + avgCost: new Prisma.Decimal('1400.25'), + }; + + const mockTransaction = { + id: BigInt(4), + isaAccountId, + etfId, + quantity: new Prisma.Decimal('2.5'), + transactionType: 'CHALLENGE_REWARD', + price: new Prisma.Decimal('1500.75'), + }; + + mockTx.challenge.findUnique.mockResolvedValue(mockChallenge); + mockTx.user.findUnique.mockResolvedValue(mockUser); + mockTx.etfDailyTrading.findFirst.mockResolvedValue(mockLatestTrading); + mockTx.userChallengeClaim.create.mockResolvedValue({}); + mockTx.userChallengeProgress.updateMany.mockResolvedValue({}); + mockTx.eTFTransaction.create.mockResolvedValue(mockTransaction); + mockTx.eTFHolding.findUnique.mockResolvedValue(mockExistingHolding); + mockTx.eTFHolding.upsert.mockResolvedValue({}); + + const result = await claimChallengeReward({ challengeId, userId }, mockTx); + + expect(result.success).toBe(true); + expect(result.transactionId).toBe(BigInt(4)); + + // 평균 단가 검증 + const totalQty = mockExistingHolding.quantity.add(mockChallenge.quantity); // 3.5 + 2.5 = 6.0 + const totalCost = mockExistingHolding.avgCost + .mul(mockExistingHolding.quantity) // 1400.25 * 3.5 + .add(mockChallenge.quantity.mul(mockLatestTrading.tddClosePrice)); // 2.5 * 1500.75 + const expectedAvgCost = totalCost.div(totalQty); // (4900.875 + 3751.875) / 6.0 = 8652.75 / 6.0 + + expect(mockTx.eTFHolding.upsert).toHaveBeenCalledWith({ + where: { + isaAccountId_etfId: { + isaAccountId, + etfId, + }, + }, + update: { + quantity: { increment: new Prisma.Decimal('2.5') }, + avgCost: expectedAvgCost, + updatedAt: expect.any(Date), + }, + create: { + isaAccountId, + etfId, + quantity: new Prisma.Decimal('2.5'), + avgCost: expectedAvgCost, + acquiredAt: expect.any(Date), + updatedAt: expect.any(Date), + }, + }); + }); +}); From 273b83cc8e976fff7ca11bf4b473ec3a2957326f Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 16:39:47 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=88=98=EB=A0=B9=20API=20(/api/challenge?= =?UTF-8?q?/claim)=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/api/challenge-claim-api.test.ts | 188 ++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 __tests__/api/challenge-claim-api.test.ts diff --git a/__tests__/api/challenge-claim-api.test.ts b/__tests__/api/challenge-claim-api.test.ts new file mode 100644 index 0000000..f6b4791 --- /dev/null +++ b/__tests__/api/challenge-claim-api.test.ts @@ -0,0 +1,188 @@ +/** + * @jest-environment node + */ +import { getServerSession } from 'next-auth'; +import { resetWithChallengePrismaMock } from '@/__mocks__/prisma'; +import { POST } from '@/app/api/challenge/claim/route'; +import { claimChallengeReward } from '@/services/challenge/challenge-claim'; +import { canClaimChallenge } from '@/services/challenge/challenge-status'; + +// Mock dependencies +jest.mock('next-auth'); +jest.mock('@/services/challenge/challenge-status'); +jest.mock('@/services/challenge/challenge-claim'); +jest.mock('@/lib/prisma', () => { + const { getPrismaMock } = require('@/__mocks__/prisma'); + return { + get prisma() { + return getPrismaMock(); + }, + }; +}); + +const mockGetServerSession = getServerSession as jest.MockedFunction< + typeof getServerSession +>; +const mockCanClaimChallenge = canClaimChallenge as jest.MockedFunction< + typeof canClaimChallenge +>; +const mockClaimChallengeReward = claimChallengeReward as jest.MockedFunction< + typeof claimChallengeReward +>; + +describe('/api/challenge/claim', () => { + let mockPrisma: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma = resetWithChallengePrismaMock(); + }); + + describe('POST', () => { + it('should return 401 when not authenticated', async () => { + mockGetServerSession.mockResolvedValue(null); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.message).toBe('Unauthorized'); + }); + + it('should return 400 when challengeId is missing', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({}), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe('Challenge ID is required'); + }); + + it('should successfully claim challenge reward', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const mockTx = mockPrisma; + return await callback(mockTx); + }); + + mockCanClaimChallenge.mockResolvedValue({ canClaim: true }); + mockClaimChallengeReward.mockResolvedValue({ + success: true, + message: 'Reward claimed successfully', + transactionId: BigInt(123), + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.message).toBe('Reward claimed successfully'); + expect(data.transactionId).toBe('123'); + + expect(mockCanClaimChallenge).toHaveBeenCalledWith( + BigInt(1), + BigInt(1), + mockPrisma + ); + expect(mockClaimChallengeReward).toHaveBeenCalledWith( + { challengeId: BigInt(1), userId: BigInt(1) }, + mockPrisma + ); + }); + + it('should return 500 when cannot claim challenge', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const mockTx = mockPrisma; + return await callback(mockTx); + }); + + mockCanClaimChallenge.mockResolvedValue({ + canClaim: false, + reason: 'Already claimed', + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe('Already claimed'); + }); + + it('should return 400 when claim service returns failure', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const mockTx = mockPrisma; + return await callback(mockTx); + }); + + mockCanClaimChallenge.mockResolvedValue({ canClaim: true }); + mockClaimChallengeReward.mockResolvedValue({ + success: false, + message: 'ISA account not found', + }); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe('ISA account not found'); + }); + + it('should handle unexpected errors', async () => { + mockGetServerSession.mockResolvedValue({ + user: { id: '1' }, + }); + + mockPrisma.$transaction.mockRejectedValue(new Error('Database error')); + + const request = new Request('http://localhost/api/challenge/claim', { + method: 'POST', + body: JSON.stringify({ challengeId: '1' }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe('Database error'); + }); + }); +}); From f1447f27618e044a74d14868dfd5a4c90a1e81e6 Mon Sep 17 00:00:00 2001 From: Hyejeong Date: Sat, 28 Jun 2025 16:44:13 +0900 Subject: [PATCH 10/10] chore: remove unused files --- __tests__/api/etf-portfolio-api.test.ts | 26 ------------------------- 1 file changed, 26 deletions(-) delete mode 100644 __tests__/api/etf-portfolio-api.test.ts diff --git a/__tests__/api/etf-portfolio-api.test.ts b/__tests__/api/etf-portfolio-api.test.ts deleted file mode 100644 index f1a623b..0000000 --- a/__tests__/api/etf-portfolio-api.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @jest-environment node - */ -import { getServerSession } from 'next-auth'; -import { NextRequest } from 'next/server'; -import { GET } from '@/app/api/etf/portfolio/route'; - -jest.mock('next-auth'); - -describe('실제 DB - GET /api/etf/portfolio', () => { - it('실제 데이터를 확인한다', async () => { - // 실제 로그인된 유저 세션 ID로 설정 (테스트용) - (getServerSession as jest.Mock).mockResolvedValue({ - user: { id: '59' }, // 실제 DB에 있는 유저 ID로 대체해야 함 - }); - - const req = new NextRequest('http://localhost:3000/api/etf/portfolio'); - const res = await GET(req); - const json = await res.json(); - - console.dir(json, { depth: null }); // 콘솔에서 전체 구조 확인 - - expect(res.status).toBe(200); - expect(Array.isArray(json.data)).toBe(true); - }); -});