From e2cbca1b845c5d957790ff42c36df8b30f5152e4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 12:25:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin-calculate):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=99=80=20=EC=A0=95=EC=82=B0=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=8B=9D=EB=B3=84=EC=9E=90=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260316_라이브환불기능추가.md | 58 +++++ .../calculate/AdminCalculateController.kt | 5 + .../AdminCalculateQueryRepository.kt | 2 +- .../admin/calculate/AdminCalculateService.kt | 113 +++++++-- .../admin/calculate/AdminLiveRefundRequest.kt | 8 + .../calculate/GetCalculateLiveQueryData.kt | 5 +- .../calculate/GetCalculateLiveResponse.kt | 2 +- .../vividnext/sodalive/can/CanRepository.kt | 13 + .../CreatorAdminCalculateQueryRepository.kt | 2 +- .../calculate/AdminCalculateServiceTest.kt | 224 ++++++++++++++++++ 10 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 docs/20260316_라이브환불기능추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt diff --git a/docs/20260316_라이브환불기능추가.md b/docs/20260316_라이브환불기능추가.md new file mode 100644 index 00000000..3b17e704 --- /dev/null +++ b/docs/20260316_라이브환불기능추가.md @@ -0,0 +1,58 @@ +# 20260316_라이브환불기능추가 + +## 구현 항목 +- [x] `GetCalculateLiveQueryData`에 `roomId` 필드 추가 및 `toGetCalculateLiveResponse` 수정 (email 제거 예정) +- [x] `GetCalculateLiveResponse`에 `roomId` 필드 추가 (email 제거 예정) +- [x] 환불 요청 시 `roomId`, `canUsageStr` 필수 조건 확인 로직 추가 +- [x] `LiveRoomService` 내 환불 처리 로직 구현 (1차 수정: `cancelLive`와 동일하게 예약자 대상) +- [x] 환불 요청 API 엔드포인트 구현 (또는 수정) +- [x] `GetCalculateLiveQueryData` 및 `GetCalculateLiveResponse`에서 `email` 필드 제거 +- [x] `AdminCalculateQueryRepository` 및 `CreatorAdminCalculateQueryRepository`에서 `email` 조회 제거 +- [x] 환불 대상을 '예약자'가 아닌 '해당 라이브 및 사용 조건에 맞는 모든 미환불 UseCan'으로 변경 +- [x] `LiveRoomService`의 `refundLiveByAdmin` 로직을 `AdminCalculateService`로 이동 및 수정 +- [x] 이미 환불 처리된 건은 환불하지 않도록 재검증 +- [x] 사용 전/후/환불 후 캔 수 일치 여부 검증 테스트 추가 +- [x] 테스트 코드에 DisplayName을 사용하여 한글 설명 추가 +- [x] 환불 실패 케이스에 대한 테스트 추가 + +## 검증 결과 +### 1차 구현 +- 무엇을: 라이브 환불 기능 추가 +- 왜: 관리자 정산 페이지 등에서 라이브별 환불 처리를 지원하기 위함 +- 어떻게: + - [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse` 수정 확인 + - [x] 환불 요청 API 호출 및 `LiveRoomService.refundLiveByAdmin` 로직 실행 여부 확인 + - [x] 테스트 코드(`AdminCalculateServiceTest`) 작성 및 실행 결과 확인 (성공) + +### 2차 수정 (잘못된 처리 반영) +- 무엇을: 라이브 환불 로직 수정 및 필드 정리 +- 왜: 환불은 예약자 기준이 아니며, 관리자 기능이므로 관리자 서비스에서 처리해야 함. 또한 개인정보 보호 등을 위해 불필요한 `email` 필드 제거. +- 어떻게: + - [x] `GetCalculateLiveQueryData`, `GetCalculateLiveResponse`에서 `email` 제거 확인 + - [x] '모든 미환불 UseCan' 대상 환불 로직 검증 (테스트 코드 수정 및 실행) + - [x] `LiveRoomService`에서 해당 로직 제거 및 `AdminCalculateService`에서 직접 처리 확인 + +### 3차 수정 (캔 수 검증 테스트 추가) +- 무엇을: 환불 시 사용자의 캔 수 변화 검증 테스트 추가 +- 왜: 환불 후 사용자의 캔 수가 사용 전과 동일한지 확인하여 정합성을 보장하기 위함 +- 어떻게: + - [x] `AdminCalculateServiceTest`에 `shouldMaintainCanBalanceAfterRefund` 테스트 추가 + - [x] 사용 전, 사용 후(시뮬레이션), 환불 후 캔 수를 비교하여 사용 전과 환불 후가 동일함을 검증 + - [x] `./gradlew test` 실행 결과 성공 확인 + +### 4차 수정 (테스트 코드 가독성 개선) +- 무엇을: 테스트 코드에 `@DisplayName`을 사용하여 한글 설명 추가 +- 왜: 테스트의 의도를 보다 명확하게 전달하기 위함 +- 어떻게: + - [x] `AdminCalculateServiceTest.kt`의 모든 테스트 메서드에 `@DisplayName` 적용 + - [x] `./gradlew test` 실행 시 한글 설명이 정상적으로 출력됨을 확인 + +### 5차 수정 (환불 실패 케이스 테스트 추가) +- 무엇을: 환불이 실패하는 예외 상황에 대한 테스트 케이스 추가 +- 왜: 환불 요청 중 발생 가능한 예외 상황(잘못된 방 ID, 잘못된 구분 값, 파라미터 누락 등)을 사전에 검증하기 위함 +- 어떻게: + - [x] `AdminCalculateServiceTest.kt`에 3개의 실패 테스트 추가 + - `shouldFailWhenRoomNotFound`: 존재하지 않는 방 ID 요청 시 `live.room.not_found` 예외 검증 + - `shouldFailWhenInvalidCanUsage`: 지원하지 않는 사용 구분 문자열 요청 시 예외 검증 + - `shouldFailWhenRequiredParameterMissing`: 필수 파라미터 누락 시 `common.error.invalid_request` 예외 검증 + - [x] `./gradlew test` 실행 결과 5개의 테스트 모두 성공 확인 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt index 1e172ce3..7357c7af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateController.kt @@ -7,6 +7,8 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -18,6 +20,9 @@ import java.nio.charset.StandardCharsets @PreAuthorize("hasRole('ADMIN')") @RequestMapping("/admin/calculate") class AdminCalculateController(private val service: AdminCalculateService) { + @PostMapping("/live/refund") + fun refundLive(@RequestBody request: AdminLiveRefundRequest) = ApiResponse.ok(service.refundLive(request)) + @GetMapping("/live") fun getCalculateLive( @RequestParam startDateStr: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt index 5640c572..4d8e7de3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt @@ -50,10 +50,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { return queryFactory .select( QGetCalculateLiveQueryData( - member.email, member.nickname, formattedDate, liveRoom.title, + liveRoom.id, liveRoom.price, useCan.canUsage, useCan.id.count(), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt index eb96883c..8e06fff1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt @@ -1,17 +1,102 @@ package kr.co.vividnext.sodalive.admin.calculate +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.xssf.streaming.SXSSFWorkbook import org.springframework.cache.annotation.Cacheable +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.time.LocalDateTime @Service -class AdminCalculateService(private val repository: AdminCalculateQueryRepository) { +class AdminCalculateService( + private val repository: AdminCalculateQueryRepository, + private val canRepository: CanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val chargeRepository: ChargeRepository, + private val liveRoomRepository: LiveRoomRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang).orEmpty() + return if (args.isNotEmpty()) { + String.format(template, *args) + } else { + template + } + } + + @Transactional + fun refundLive(request: AdminLiveRefundRequest) { + if (request.roomId == null || request.canUsageStr.isNullOrBlank()) { + throw SodaException(messageKey = "common.error.invalid_request") + } + + val room = liveRoomRepository.findByIdOrNull(request.roomId) + ?: throw SodaException(messageKey = "live.room.not_found") + + val canUsage = when (request.canUsageStr) { + "유료" -> CanUsage.LIVE + "룰렛" -> CanUsage.SPIN_ROULETTE + "하트" -> CanUsage.HEART + "후원" -> CanUsage.DONATION + else -> throw SodaException(message = "Invalid canUsageStr: ${request.canUsageStr}") + } + + val useCanList = canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse( + roomId = room.id!!, + canUsage = canUsage + ) + + for (useCan in useCanList) { + useCan.isRefund = true + val member = useCan.member!! + + val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!) + useCanCalculate.forEach { + it.status = UseCanCalculateStatus.REFUND + + val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) + charge.title = formatMessage("live.room.can_title", it.can) + charge.useCan = useCan + + when (it.paymentGateway) { + PaymentGateway.GOOGLE_IAP -> member.googleRewardCan += charge.rewardCan + PaymentGateway.APPLE_IAP -> member.appleRewardCan += charge.rewardCan + else -> member.pgRewardCan += charge.rewardCan + } + charge.member = member + + val payment = Payment( + status = PaymentStatus.COMPLETE, + paymentGateway = it.paymentGateway + ) + payment.method = formatMessage("live.room.refund_method") + charge.payment = payment + + chargeRepository.save(charge) + } + } + } + @Transactional(readOnly = true) fun getCalculateLive( startDateStr: String, @@ -164,7 +249,6 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor return createExcelStream( sheetName = "라이브 정산", headers = listOf( - "이메일", "닉네임", "날짜", "라이브 제목", @@ -181,19 +265,18 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor ) { sheet -> items.forEachIndexed { index, item -> val row = sheet.createRow(index + 1) - row.createCell(0).setCellValue(item.email) - row.createCell(1).setCellValue(item.nickname) - row.createCell(2).setCellValue(item.date) - row.createCell(3).setCellValue(item.title) - row.createCell(4).setCellValue(item.entranceFee.toDouble()) - row.createCell(5).setCellValue(item.canUsageStr) - row.createCell(6).setCellValue(item.numberOfPeople.toDouble()) - row.createCell(7).setCellValue(item.totalAmount.toDouble()) - row.createCell(8).setCellValue(item.totalKrw.toDouble()) - row.createCell(9).setCellValue(item.paymentFee.toDouble()) - row.createCell(10).setCellValue(item.settlementAmount.toDouble()) - row.createCell(11).setCellValue(item.tax.toDouble()) - row.createCell(12).setCellValue(item.depositAmount.toDouble()) + row.createCell(0).setCellValue(item.nickname) + row.createCell(1).setCellValue(item.date) + row.createCell(2).setCellValue(item.title) + row.createCell(3).setCellValue(item.entranceFee.toDouble()) + row.createCell(4).setCellValue(item.canUsageStr) + row.createCell(5).setCellValue(item.numberOfPeople.toDouble()) + row.createCell(6).setCellValue(item.totalAmount.toDouble()) + row.createCell(7).setCellValue(item.totalKrw.toDouble()) + row.createCell(8).setCellValue(item.paymentFee.toDouble()) + row.createCell(9).setCellValue(item.settlementAmount.toDouble()) + row.createCell(10).setCellValue(item.tax.toDouble()) + row.createCell(11).setCellValue(item.depositAmount.toDouble()) } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt new file mode 100644 index 00000000..2b79bdf3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminLiveRefundRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.admin.calculate + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AdminLiveRefundRequest( + @JsonProperty("roomId") val roomId: Long?, + @JsonProperty("canUsageStr") val canUsageStr: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt index f23d21a8..3eb758c6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveQueryData.kt @@ -6,10 +6,11 @@ import java.math.BigDecimal import java.math.RoundingMode data class GetCalculateLiveQueryData @QueryProjection constructor( - val email: String, val nickname: String, val date: String, val title: String, + // 라이브 방 id + val roomId: Long, // 유료방 입장 금액 val entranceFee: Int, // 코인 사용 구분 @@ -66,10 +67,10 @@ data class GetCalculateLiveQueryData @QueryProjection constructor( val depositAmount = settlementAmount.subtract(tax) return GetCalculateLiveResponse( - email = email, nickname = nickname, date = date, title = title, + roomId = roomId, entranceFee = entranceFee, canUsageStr = canUsageStr, numberOfPeople = numberOfPeople, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt index 8b71d50a..4f64371f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateLiveResponse.kt @@ -3,10 +3,10 @@ package kr.co.vividnext.sodalive.admin.calculate import com.fasterxml.jackson.annotation.JsonProperty data class GetCalculateLiveResponse( - @JsonProperty("email") val email: String, @JsonProperty("nickname") val nickname: String, @JsonProperty("date") val date: String, @JsonProperty("title") val title: String, + @JsonProperty("roomId") val roomId: Long, @JsonProperty("entranceFee") val entranceFee: Int, @JsonProperty("canUsageStr") val canUsageStr: String, @JsonProperty("numberOfPeople") val numberOfPeople: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index 0019c22e..1c0dcea6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -28,6 +28,7 @@ interface CanQueryRepository { fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? + fun findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId: Long, canUsage: CanUsage): List } @Repository @@ -139,4 +140,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue .orderBy(useCan.id.desc()) .fetchFirst() } + + override fun findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId: Long, canUsage: CanUsage): List { + return queryFactory + .selectFrom(useCan) + .innerJoin(useCan.room, liveRoom) + .where( + liveRoom.id.eq(roomId) + .and(useCan.canUsage.eq(canUsage)) + .and(useCan.isRefund.isFalse) + ) + .fetch() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt index d4267c42..eba2d596 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt @@ -38,10 +38,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac return queryFactory .select( QGetCalculateLiveQueryData( - member.email, member.nickname, formattedDate, liveRoom.title, + liveRoom.id, liveRoom.price, useCan.canUsage, useCan.id.count(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt new file mode 100644 index 00000000..1914261f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt @@ -0,0 +1,224 @@ +package kr.co.vividnext.sodalive.admin.calculate + +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.member.Member +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.util.Optional + +class AdminCalculateServiceTest { + private val repository = mock(AdminCalculateQueryRepository::class.java) + private val canRepository = mock(CanRepository::class.java) + private val useCanCalculateRepository = mock(UseCanCalculateRepository::class.java) + private val chargeRepository = mock(ChargeRepository::class.java) + private val liveRoomRepository = mock(LiveRoomRepository::class.java) + private val messageSource = mock(SodaMessageSource::class.java) + private val langContext = mock(LangContext::class.java) + + private val adminCalculateService = AdminCalculateService( + repository, + canRepository, + useCanCalculateRepository, + chargeRepository, + liveRoomRepository, + messageSource, + langContext + ) + + @Test + @DisplayName("라이브 환불 성공 테스트") + fun shouldRefundLiveSuccessfully() { + // given + val roomId = 1L + val canUsageStr = "유료" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + val member = Member(password = "pass", nickname = "nick").apply { + id = 10L + pgRewardCan = 100 + } + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = java.time.LocalDateTime.now(), + numberOfPeople = 10, + isAdult = false + ).apply { id = roomId } + + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = 10, + rewardCan = 0 + ).apply { + id = 100L + this.member = member + this.room = room + this.isRefund = false + } + + val useCanCalculate = UseCanCalculate( + can = 10, + paymentGateway = PaymentGateway.PG, + status = UseCanCalculateStatus.RECEIVED + ).apply { + id = 1000L + this.useCan = useCan + } + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.of(room)) + `when`(canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId, CanUsage.LIVE)) + .thenReturn(listOf(useCan)) + `when`(useCanCalculateRepository.findByUseCanIdAndStatus(100L)) + .thenReturn(listOf(useCanCalculate)) + + // when + adminCalculateService.refundLive(request) + + // then + assertTrue(useCan.isRefund) + verify(chargeRepository, atLeastOnce()).save(any()) + } + + @Test + @DisplayName("환불 전후 캔 잔액 동일성 검증 테스트") + fun shouldMaintainCanBalanceAfterRefund() { + // given + val roomId = 1L + val canUsageStr = "유료" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + val initialPgRewardCan = 100 + val spendCanAmount = 30 + + val member = Member(password = "pass", nickname = "nick").apply { + id = 10L + pgRewardCan = initialPgRewardCan + } + + // 사용 전 캔 수 확인 + val beforeUseCanCount = member.pgRewardCan + assertEquals(initialPgRewardCan, beforeUseCanCount) + + // 캔 사용 시뮬레이션 + member.pgRewardCan -= spendCanAmount + val afterUseCanCount = member.pgRewardCan + assertEquals(initialPgRewardCan - spendCanAmount, afterUseCanCount) + + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = java.time.LocalDateTime.now(), + numberOfPeople = 10, + isAdult = false + ).apply { id = roomId } + + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = spendCanAmount, + rewardCan = 0 + ).apply { + id = 100L + this.member = member + this.room = room + this.isRefund = false + } + + val useCanCalculate = UseCanCalculate( + can = spendCanAmount, + paymentGateway = PaymentGateway.PG, + status = UseCanCalculateStatus.RECEIVED + ).apply { + id = 1000L + this.useCan = useCan + } + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.of(room)) + `when`(canRepository.findAllByRoomIdAndCanUsageAndIsRefundFalse(roomId, CanUsage.LIVE)) + .thenReturn(listOf(useCan)) + `when`(useCanCalculateRepository.findByUseCanIdAndStatus(100L)) + .thenReturn(listOf(useCanCalculate)) + + // when (환불 실행) + adminCalculateService.refundLive(request) + + // then (환불 후 캔 수 확인) + val afterRefundCanCount = member.pgRewardCan + assertEquals(beforeUseCanCount, afterRefundCanCount, "사용 전 캔 수와 환불 후 캔 수가 동일해야 합니다.") + assertTrue(useCan.isRefund) + } + + @Test + @DisplayName("존재하지 않는 라이브 방 환불 요청 시 실패 테스트") + fun shouldFailWhenRoomNotFound() { + // given + val roomId = 999L + val canUsageStr = "유료" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.empty()) + + // when & then + val exception = assertThrows(SodaException::class.java) { + adminCalculateService.refundLive(request) + } + assertEquals("live.room.not_found", exception.messageKey) + } + + @Test + @DisplayName("잘못된 사용 구분 문자열로 환불 요청 시 실패 테스트") + fun shouldFailWhenInvalidCanUsage() { + // given + val roomId = 1L + val canUsageStr = "잘못된구분" + val request = AdminLiveRefundRequest(roomId, canUsageStr) + + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = java.time.LocalDateTime.now(), + numberOfPeople = 10, + isAdult = false + ).apply { id = roomId } + + `when`(liveRoomRepository.findById(roomId)).thenReturn(Optional.of(room)) + + // when & then + val exception = assertThrows(SodaException::class.java) { + adminCalculateService.refundLive(request) + } + assertTrue(exception.message!!.contains("Invalid canUsageStr")) + } + + @Test + @DisplayName("필수 파라미터 누락 시 환불 요청 실패 테스트") + fun shouldFailWhenRequiredParameterMissing() { + // given + val request = AdminLiveRefundRequest(null, "") + + // when & then + val exception = assertThrows(SodaException::class.java) { + adminCalculateService.refundLive(request) + } + assertEquals("common.error.invalid_request", exception.messageKey) + } +}