Compare commits

...

3 Commits

8 changed files with 410 additions and 66 deletions

View File

@@ -83,11 +83,19 @@
- 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다. - 생성자 주입(primary constructor + `private val`)을 기본으로 사용한다.
- 필드 주입보다 명시적 생성자 주입을 우선한다. - 필드 주입보다 명시적 생성자 주입을 우선한다.
### 10) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
## 테스트 스타일 규칙 ## 테스트 스타일 규칙
- 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`) - 테스트 프레임워크: JUnit 5 (`useJUnitPlatform()`)
- 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``) - 목킹: Mockito 사용 패턴 존재 (`Mockito.mock`, ``Mockito.`when`(...)``)
- 검증: `assertEquals`, `assertThrows` 패턴 준수. - 검증: `assertEquals`, `assertThrows` 패턴 준수.
- 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다. - 테스트 이름은 의도가 드러나는 영어 문장형(`should...`)을 유지한다.
- 테스트는 DisplayName으로 한국어 설명을 추가한다.
- 예외 상황이 있는지 확인하고 예외 상황에 대한 테스트 케이스를 추가한다.
## 설정/보안 유의사항 ## 설정/보안 유의사항
- `application.yml`은 다수의 `${ENV_VAR}`를 사용한다. - `application.yml`은 다수의 `${ENV_VAR}`를 사용한다.

View File

@@ -0,0 +1,14 @@
# 20260316_작업문서한글명변경.md
## 구현 항목
- [x] 이번 세션에서 생성된 영문 작업 문서 이름 변경
- [x] `docs/20260316_CanServiceGetCanUseStatusRefactoring.md` -> `docs/20260316_캔사용내역조회리팩토링.md`
- [x] `docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md` -> `docs/20260316_캔사용내역타임존및널처리개선.md`
## 검증 결과
### 1차 구현
- 무엇을: 이번 세션에서 생성된 작업 문서 2개의 이름을 한글로 변경
- 왜: 작업 계획 문서의 파일명 형식([날짜]_구현할내용한글.md)을 준수하기 위해
- 어떻게: bash 명령어로 `mv` 실행
- `mv docs/20260316_CanServiceGetCanUseStatusRefactoring.md docs/20260316_캔사용내역조회리팩토링.md`
- `mv docs/20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md docs/20260316_캔사용내역타임존및널처리개선.md`

View File

@@ -0,0 +1,40 @@
# 20260316_CanServiceGetCanUseStatusRefactoring.md
## 작업 목표
- `CanService.getCanUseStatus` 함수의 비효율적인 필터링 및 데이터 로딩 로직 개선.
- Kotlin 레벨에서 수행하던 필터링을 DB 레벨(Querydsl)로 이동.
- Entity 전체를 조회하는 대신 필요한 필드만 조회(Query Projection)하도록 리팩토링.
## 작업 내용
- [x] `CanService.getCanUseStatus` 현재 기능 검증용 테스트 코드 작성.
- [x] `UseCanQueryDto` 생성 (QueryProjection용 DTO).
- [x] `CanRepository`에 Querydsl 기반의 고도화된 `getCanUseStatus` 추가 (또는 기존 메서드 수정).
- [x] `member.id` 필터링 (기존 유지).
- [x] `(can + rewardCan) > 0` 필터링.
- [x] `container`(`aos`, `ios`, `else`)별 `paymentGateway` 필터링 (Join 사용).
- [x] 필요한 연관 엔티티(`Member`, `Room`, `AudioContent` 등)의 필드만 선택적으로 조회.
- [x] `CanService.getCanUseStatus` 리팩토링.
- [x] 리포지토리에서 바로 DTO 또는 필요한 데이터만 받아오도록 수정.
- [x] Kotlin `filter` 제거.
- [x] Kotlin `map` 로직 단순화 또는 QueryProjection으로 흡수 가능한지 판단하여 처리.
- [x] 작성한 테스트 코드로 기능 검증.
- [x] 테스트 코드에 `@DisplayName` 추가 및 예외/엣지 케이스 테스트 보강.
- [x] 성능 및 쿼리 최적화 확인.
## 검증 결과
- **기능 검증**:
- `CanServiceTest.kt`를 작성하여 리팩토링 전후의 필터링 및 맵핑 로직이 동일하게 유지됨을 확인.
- `@DisplayName`을 추가하여 테스트 의도를 명확히 기술.
- 유효하지 않은 타임존 입력 시 `DateTimeException`이 발생하는 예외 케이스 추가.
- 데이터가 없을 때 빈 리스트 반환 및 각 `CanUsage`별 nullable 필드(닉네임, 제목 등)가 누락되었을 때의 기본 타이틀 처리 로직 검증.
- **성능 개선**:
- Kotlin 레벨의 필터링을 DB 레벨(Querydsl)로 이동하여 불필요한 데이터 조회를 줄이고 페이지네이션 정확도 향상.
- Entity 전체 조회 대신 필요한 12개 필드만 조회하는 `UseCanQueryDto` 사용 (Projection).
- `CHANNEL_DONATION` 시 별도의 Member 조회를 위해 발생하던 N+1 또는 추가 쿼리를 Join을 통해 1번의 쿼리로 최적화.
- **코드 품질**:
- `CanService`에서 더 이상 사용하지 않는 `memberRepository` 의존성 제거.
- 복잡한 맵핑 로직을 QueryProjection DTO 기반으로 깔끔하게 정리.
### 단계별 검증 내용
1. **1차 구현 및 단위 테스트**: `CanServiceTest`를 통해 `aos`, `ios` 컨테이너별 필터링 조건이 올바르게 DB 쿼리에 반영되고 결과가 맵핑되는지 검증 (성공).
2. **쿼리 최적화 확인**: `UseCanCalculate` 및 관련 엔티티들을 `leftJoin``innerJoin`을 통해 한 번의 쿼리로 가져오도록 구현됨을 코드 레벨에서 확인.

View File

@@ -0,0 +1,25 @@
# 20260316_CanServiceGetCanUseStatusTimezoneAndNullHandling.md
## 작업 개요
- `CanService.getCanUseStatus` 함수에서 유효하지 않은 타임존 입력 시 처리 방식 변경 (예외 발생 -> UTC 기본값 사용).
- 캔 사용 내역 타이틀에서 `null` 문자열이 노출되는 문제 해결 및 크리에이터 닉네임 활용 로직 강화.
## 구현 항목
- [x] `CanService.getCanUseStatus` 타임존 처리 로직 수정
- `ZoneId.of(timezone)` 호출 시 예외 발생 시 `UTC`를 기본값으로 사용하도록 변경.
- [x] `CanService.getCanUseStatus` 타이틀 생성 로직 수정
- `CanUsage.LIVE` 등에서 `roomTitle`이 null인 경우 `roomMemberNickname`을 출력하도록 변경.
- 기타 `null` 문자열이 노출될 수 있는 지점 확인 및 수정.
- [x] `CanServiceTest.kt` 수정
- 타임존 예외 테스트를 UTC 기본값 동작 검증 테스트로 변경.
- 타이틀 `null` 처리 로직 변경에 따른 검증 코드 업데이트.
## 검증 기록
### 1차 구현
- **무엇을**: 타임존 안전 처리 및 타이틀 null 방지 로직 구현
- **왜**: 사용자 경험 개선 및 데이터 무결성 표시
- **어떻게**:
- `CanService.kt`: `ZoneId.of(timezone)`에 try-catch 적용, `CanUsage.LIVE` 등에서 제목 null 시 닉네임 사용하도록 수정.
- `CanServiceTest.kt`: 타임존 UTC 폴백 테스트 및 타이틀 null 방지 테스트 케이스 업데이트.
- `./gradlew test` 실행 결과: 5개 테스트 모두 통과.
- `./gradlew ktlintCheck` 실행 결과: 성공.

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.can package kr.co.vividnext.sodalive.can
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.audition.QAudition.audition
import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant
import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole
import kr.co.vividnext.sodalive.can.QCan.can1 import kr.co.vividnext.sodalive.can.QCan.can1
import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.charge.ChargeStatus
@@ -10,7 +13,12 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember
@@ -24,7 +32,7 @@ interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
interface CanQueryRepository { interface CanQueryRepository {
fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List<CanResponse> fun findAllByStatusAndCurrency(status: CanStatus, currency: String?): List<CanResponse>
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List<UseCanQueryDto>
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan?
@@ -58,13 +66,68 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
.fetch() .fetch()
} }
override fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> { override fun getCanUseStatus(member: Member, pageable: Pageable, container: String): List<UseCanQueryDto> {
val qRoomMember = QMember("roomMember")
val qAudioContentMember = QMember("audioContentMember")
val qCommunityPostMember = QMember("communityPostMember")
val qRecipientMember = QMember("recipientMember")
val gatewayCondition = when (container) {
"aos" -> useCanCalculate.paymentGateway.`in`(
PaymentGateway.PG,
PaymentGateway.PAYVERSE,
PaymentGateway.GOOGLE_IAP
)
"ios" -> useCanCalculate.paymentGateway.`in`(
PaymentGateway.PG,
PaymentGateway.PAYVERSE,
PaymentGateway.APPLE_IAP
)
else -> useCanCalculate.paymentGateway.`in`(PaymentGateway.PG, PaymentGateway.PAYVERSE)
}
return queryFactory return queryFactory
.selectFrom(useCan) .select(
.where(useCan.member.id.eq(member.id)) QUseCanQueryDto(
useCan.canUsage,
useCan.can,
useCan.rewardCan,
useCan.createdAt,
qRoomMember.nickname,
liveRoom.title,
qAudioContentMember.nickname,
audioContent.title,
qCommunityPostMember.nickname,
audition.title,
chatCharacter.name,
qRecipientMember.nickname
)
)
.from(useCan)
.leftJoin(useCan.room, liveRoom)
.leftJoin(liveRoom.member, qRoomMember)
.leftJoin(useCan.audioContent, audioContent)
.leftJoin(audioContent.member, qAudioContentMember)
.leftJoin(useCan.communityPost, creatorCommunity)
.leftJoin(creatorCommunity.member, qCommunityPostMember)
.leftJoin(useCan.auditionApplicant, auditionApplicant)
.leftJoin(auditionApplicant.role, auditionRole)
.leftJoin(auditionRole.audition, audition)
.leftJoin(useCan.characterImage, characterImage)
.leftJoin(characterImage.chatCharacter, chatCharacter)
.innerJoin(useCan.useCanCalculates, useCanCalculate)
.leftJoin(qRecipientMember).on(useCanCalculate.recipientCreatorId.eq(qRecipientMember.id))
.where(
useCan.member.id.eq(member.id)
.and(useCan.can.add(useCan.rewardCan).gt(0))
.and(gatewayCondition)
)
.offset(pageable.offset) .offset(pageable.offset)
.limit(pageable.pageSize.toLong()) .limit(pageable.pageSize.toLong())
.orderBy(useCan.id.desc()) .orderBy(useCan.id.desc())
.distinct()
.fetch() .fetch()
} }

View File

@@ -1,11 +1,9 @@
package kr.co.vividnext.sodalive.can package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.ZoneId import java.time.ZoneId
@@ -14,8 +12,7 @@ import java.time.format.DateTimeFormatter
@Service @Service
class CanService( class CanService(
private val repository: CanRepository, private val repository: CanRepository,
private val countryContext: CountryContext, private val countryContext: CountryContext
private val memberRepository: MemberRepository
) { ) {
fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> { fun getCans(isNotSelectedCurrency: Boolean): List<CanResponse> {
val currency = if (isNotSelectedCurrency) { val currency = if (isNotSelectedCurrency) {
@@ -42,88 +39,94 @@ class CanService(
timezone: String, timezone: String,
container: String container: String
): List<GetCanUseStatusResponseItem> { ): List<GetCanUseStatusResponseItem> {
val useCanList = repository.getCanUseStatus(member, pageable) val zoneId = try {
.filter { (it.can + it.rewardCan) > 0 } ZoneId.of(timezone)
.filter { } catch (_: Exception) {
when (container) { ZoneId.of("UTC")
"aos" -> {
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
}
} }
"ios" -> { return repository.getCanUseStatus(member, pageable, container)
it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
}
}
else -> it.useCanCalculates.any { useCanCalculate ->
useCanCalculate.paymentGateway == PaymentGateway.PG ||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
}
}
}
val channelDonationCreatorIds = useCanList
.asSequence()
.filter { it.canUsage == CanUsage.CHANNEL_DONATION }
.mapNotNull { it.useCanCalculates.firstOrNull()?.recipientCreatorId }
.distinct()
.toList()
val channelDonationCreatorNicknameMap = if (channelDonationCreatorIds.isEmpty()) {
emptyMap()
} else {
memberRepository.findAllById(channelDonationCreatorIds).associate { it.id!! to it.nickname }
}
return useCanList
.map { .map {
val title: String = when (it.canUsage) { val title: String = when (it.canUsage) {
CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> {
if (it.room != null) { if (it.roomMemberNickname != null) {
"[라이브 후원] ${it.room!!.member!!.nickname}" "[라이브 후원] ${it.roomMemberNickname}"
} else if (it.audioContent != null) { } else if (it.audioContentMemberNickname != null) {
"[콘텐츠 후원] ${it.audioContent!!.member!!.nickname}" "[콘텐츠 후원] ${it.audioContentMemberNickname}"
} else { } else {
"[후원]" "[후원]"
} }
} }
CanUsage.CHANNEL_DONATION -> { CanUsage.CHANNEL_DONATION -> {
val creatorId = it.useCanCalculates.firstOrNull()?.recipientCreatorId if (it.recipientCreatorNickname.isNullOrBlank()) {
val creatorNickname = creatorId?.let { id -> channelDonationCreatorNicknameMap[id] }
if (creatorNickname.isNullOrBlank()) {
"[채널 후원]" "[채널 후원]"
} else { } else {
"[채널 후원] $creatorNickname" "[채널 후원] ${it.recipientCreatorNickname}"
} }
} }
CanUsage.LIVE -> { CanUsage.LIVE -> {
"[라이브] ${it.room!!.title}" if (it.roomTitle != null) {
"[라이브] ${it.roomTitle}"
} else if (it.roomMemberNickname != null) {
"[라이브] ${it.roomMemberNickname}"
} else {
"[라이브]"
}
} }
CanUsage.CHANGE_NICKNAME -> "닉네임 변경" CanUsage.CHANGE_NICKNAME -> "닉네임 변경"
CanUsage.ALARM_SLOT -> "알람 슬롯 구매" CanUsage.ALARM_SLOT -> "알람 슬롯 구매"
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" CanUsage.ORDER_CONTENT -> {
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" if (it.audioContentTitle != null) {
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" "[콘텐츠 구매] ${it.audioContentTitle}"
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" } else if (it.audioContentMemberNickname != null) {
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" "[콘텐츠 구매] ${it.audioContentMemberNickname}"
} else {
"[콘텐츠 구매]"
}
}
CanUsage.PAID_COMMUNITY_POST -> {
if (it.communityPostMemberNickname != null) {
"[게시글 보기] ${it.communityPostMemberNickname}"
} else {
"[게시글 보기]"
}
}
CanUsage.AUDITION_VOTE -> {
if (it.auditionTitle != null) {
"[오디션 투표] ${it.auditionTitle}"
} else {
"[오디션 투표]"
}
}
CanUsage.CHAT_MESSAGE_PURCHASE -> {
if (it.characterName != null) {
"[메시지 구매] ${it.characterName}"
} else {
"[메시지 구매]"
}
}
CanUsage.CHARACTER_IMAGE_PURCHASE -> {
if (it.characterName != null) {
"[캐릭터 이미지 구매] ${it.characterName}"
} else {
"[캐릭터 이미지 구매]"
}
}
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
} }
val createdAt = it.createdAt!! val createdAt = it.createdAt
.atZone(ZoneId.of("UTC")) .atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone)) .withZoneSameInstant(zoneId)
GetCanUseStatusResponseItem( GetCanUseStatusResponseItem(
title = title, title = title,
@@ -141,6 +144,12 @@ class CanService(
timezone: String, timezone: String,
container: String container: String
): List<GetCanChargeStatusResponseItem> { ): List<GetCanChargeStatusResponseItem> {
val zoneId = try {
ZoneId.of(timezone)
} catch (e: Exception) {
ZoneId.of("UTC")
}
return repository.getCanChargeStatus(member, pageable, container) return repository.getCanChargeStatus(member, pageable, container)
.map { .map {
val canTitle = it.title ?: "" val canTitle = it.title ?: ""
@@ -170,9 +179,9 @@ class CanService(
} }
} }
val createdAt = it.createdAt!! val createdAt = (it.createdAt ?: it.updatedAt!!)
.atZone(ZoneId.of("UTC")) .atZone(ZoneId.of("UTC"))
.withZoneSameInstant(ZoneId.of(timezone)) .withZoneSameInstant(zoneId)
GetCanChargeStatusResponseItem( GetCanChargeStatusResponseItem(
canTitle = canTitle, canTitle = canTitle,

View File

@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.can
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.can.use.CanUsage
import java.time.LocalDateTime
data class UseCanQueryDto @QueryProjection constructor(
val canUsage: CanUsage,
val can: Int,
val rewardCan: Int,
val createdAt: LocalDateTime,
val roomMemberNickname: String?,
val roomTitle: String?,
val audioContentMemberNickname: String?,
val audioContentTitle: String?,
val communityPostMemberNickname: String?,
val auditionTitle: String?,
val characterName: String?,
val recipientCreatorNickname: String?
)

View File

@@ -0,0 +1,165 @@
package kr.co.vividnext.sodalive.can
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.springframework.data.domain.PageRequest
import java.time.LocalDateTime
class CanServiceTest {
private lateinit var repository: CanRepository
private lateinit var countryContext: CountryContext
private lateinit var service: CanService
@BeforeEach
fun setUp() {
repository = mock(CanRepository::class.java)
countryContext = mock(CountryContext::class.java)
service = CanService(repository, countryContext)
}
@Test
@DisplayName("AOS 컨테이너에 대해 캔 사용 내역이 올바르게 필터링 및 맵핑되는지 확인한다")
fun `should filter can use status correctly for aos`() {
// given
val member = Member(nickname = "user1", password = "password")
member.id = 1L
val pageable = PageRequest.of(0, 10)
val useCanDto1 = createUseCanDto(CanUsage.HEART, 10, 0, "nick1", null)
val useCanDto2 = createUseCanDto(CanUsage.DONATION, 0, 5, "nick2", null)
`when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(listOf(useCanDto1, useCanDto2))
// when
val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos")
// then
assertEquals(2, result.size)
assertEquals("[라이브 후원] nick1", result[0].title)
assertEquals(10, result[0].can)
assertEquals("[라이브 후원] nick2", result[1].title)
assertEquals(5, result[1].can)
}
@Test
@DisplayName("iOS 컨테이너에 대해 캔 사용 내역이 올바르게 필터링 및 맵핑되는지 확인한다")
fun `should filter can use status correctly for ios`() {
// given
val member = Member(nickname = "user1", password = "password")
member.id = 1L
val pageable = PageRequest.of(0, 10)
val useCanDto1 = createUseCanDto(CanUsage.HEART, 10, 0, "nick1", null)
val useCanDto2 = createUseCanDto(CanUsage.DONATION, 10, 0, "nick2", null)
`when`(repository.getCanUseStatus(member, pageable, "ios")).thenReturn(listOf(useCanDto1, useCanDto2))
// when
val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "ios")
// then
assertEquals(2, result.size)
}
@Test
@DisplayName("조회 결과가 없을 때 빈 리스트를 반환하는지 확인한다")
fun `should return empty list when no status exists`() {
// given
val member = Member(nickname = "user1", password = "password")
member.id = 1L
val pageable = PageRequest.of(0, 10)
`when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(emptyList())
// when
val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos")
// then
assertEquals(0, result.size)
}
@Test
@DisplayName("유효하지 않은 타임존 입력 시 UTC를 기본으로 사용한다")
fun `should use UTC when timezone is invalid`() {
// given
val member = Member(nickname = "user1", password = "password")
member.id = 1L
val pageable = PageRequest.of(0, 10)
val useCanDto = createUseCanDto(CanUsage.HEART, 10, 0)
`when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(listOf(useCanDto))
// when
val result = service.getCanUseStatus(member, pageable, "Invalid/Timezone", "aos")
// then
assertEquals(1, result.size)
}
@Test
@DisplayName("다양한 CanUsage 및 null 필드에 대해 타이틀이 올바르게 포맷팅되는지 확인한다")
fun `should handle various can usage and nullable fields correctly`() {
// given
val member = Member(nickname = "user1", password = "password")
member.id = 1L
val pageable = PageRequest.of(0, 10)
val dtos = listOf(
createUseCanDto(CanUsage.HEART, 10, 0, roomMemberNickname = null, audioContentMemberNickname = null),
createUseCanDto(CanUsage.CHANNEL_DONATION, 10, 0, recipientCreatorNickname = null),
createUseCanDto(CanUsage.LIVE, 10, 0, roomMemberNickname = "creator1"),
createUseCanDto(CanUsage.LIVE, 10, 0),
createUseCanDto(CanUsage.CHANGE_NICKNAME, 10, 0),
createUseCanDto(CanUsage.CHAT_QUOTA_PURCHASE, 10, 0)
)
`when`(repository.getCanUseStatus(member, pageable, "aos")).thenReturn(dtos)
// when
val result = service.getCanUseStatus(member, pageable, "Asia/Seoul", "aos")
// then
assertEquals("[후원]", result[0].title)
assertEquals("[채널 후원]", result[1].title)
assertEquals("[라이브] creator1", result[2].title)
assertEquals("[라이브]", result[3].title)
assertEquals("닉네임 변경", result[4].title)
assertEquals("캐릭터 톡 이용권 구매", result[5].title)
}
private fun createUseCanDto(
usage: CanUsage,
can: Int,
rewardCan: Int,
roomMemberNickname: String? = null,
recipientCreatorNickname: String? = null,
roomTitle: String? = null,
audioContentMemberNickname: String? = null,
audioContentTitle: String? = null,
communityPostMemberNickname: String? = null,
auditionTitle: String? = null,
characterName: String? = null
): UseCanQueryDto {
return UseCanQueryDto(
canUsage = usage,
can = can,
rewardCan = rewardCan,
createdAt = LocalDateTime.now(),
roomMemberNickname = roomMemberNickname,
roomTitle = roomTitle,
audioContentMemberNickname = audioContentMemberNickname,
audioContentTitle = audioContentTitle,
communityPostMemberNickname = communityPostMemberNickname,
auditionTitle = auditionTitle,
characterName = characterName,
recipientCreatorNickname = recipientCreatorNickname
)
}
}