Merge pull request 'test' (#393) from test into main

Reviewed-on: #393
This commit is contained in:
2026-02-27 06:16:51 +00:00
14 changed files with 252 additions and 82 deletions

View File

@@ -0,0 +1,14 @@
- [x] Explorer 후원랭킹 집계 경로에서 후원 타입 필터 조건을 확인한다.
- [x] 크리에이터 프로필 후원랭킹 집계에 `CanUsage.CHANNEL_DONATION`을 반영하도록 쿼리를 수정한다.
- [x] 변경 범위와 연관된 테스트/검증(컴파일/테스트)을 실행한다.
- [x] 구현 완료 후 체크박스를 갱신하고 검증 기록(무엇을/왜/어떻게)을 남긴다.
## 검증 기록
- 1차 구현
- 무엇을: `CreatorDonationRankingQueryRepository`의 후원랭킹 조회/총원 집계 조건에 `CanUsage.CHANNEL_DONATION`을 추가했다.
- 왜: `ExplorerService.getCreatorProfile`의 후원랭킹이 기존 `DONATION`, `SPIN_ROULETTE`, `LIVE`만 포함해 채널 후원이 누락되고 있었기 때문이다.
- 어떻게:
- `lsp_diagnostics`로 Kotlin 파일 진단을 시도했지만, 현재 환경에 `.kt` LSP 서버가 없어 도구 기반 진단은 불가했다.
- `./gradlew test` 실행 결과: 성공
- `./gradlew build -x test` 실행 결과: 성공

View File

@@ -0,0 +1,17 @@
# 최근 종료 라이브(getLatestFinishedLive) 최적화
- [x] `getLatestFinishedLive` 조회를 DB 단계에서 차단 관계(`left join`)로 필터링하도록 변경
- [x] 조회 결과를 `GetLatestFinishedLiveResponse`로 QueryProjection 하여 서비스 단 추가 `map` 제거
- [x] 회원 차단(`memberBlock`) / 차단해제(`memberUnBlock`) 시 최근 종료 라이브 캐시 무효화 적용
- [x] 정적 진단 및 테스트/빌드 검증 수행
- [x] 검증 기록 작성
## 검증 기록
### 1차 구현
- 무엇을: `getLatestFinishedLive`를 서비스 후처리(`filter`/`map`)에서 제거하고, `LiveRoomRepository`에서 `leftJoin(blockMember)` + `blockMember.id.isNull` 조건으로 차단 관계를 DB 단계에서 제외하도록 변경했다. 또한 `GetLatestFinishedLiveResponse``@QueryProjection` 생성자를 추가해 쿼리 결과를 응답 DTO로 바로 생성했다. 마지막으로 `memberBlock`/`memberUnBlock`에서 `getLatestFinishedLive:{memberId}` 캐시를 즉시 evict 하도록 반영했다.
- 왜: 기존 로직은 조회 후 애플리케이션 레벨에서 차단 여부를 반복 조회하고 별도 `map`을 수행해 비용이 컸고, 차단/차단해제 직후 최근 종료 라이브 캐시가 TTL 만료 전까지 stale 상태가 될 수 있어 DB 레벨 필터링 및 이벤트성 캐시 무효화가 필요했다.
- 어떻게:
- `lsp_diagnostics` (대상: `GetLatestFinishedLiveResponse.kt`, `LiveRoomRepository.kt`, `LiveRoomService.kt`, `MemberService.kt`) 실행 결과: **환경상 Kotlin LSP 미구성으로 진단 불가**
- `./gradlew test && ./gradlew build` 실행 결과: **성공 (BUILD SUCCESSFUL)**
- `./gradlew tasks --all` 실행 결과: **성공 (BUILD SUCCESSFUL)**

View File

@@ -0,0 +1,45 @@
- [x] `getCreatorProfile`의 채널 후원 리스트 조회 경로 식별 (`ExplorerService` -> `ChannelDonationService` -> `ChannelDonationMessageRepository`)
- [x] 프로필 채널 후원 조회 시 조회 월의 1일~말일 범위만 조회되도록 기간 조건 반영
- [x] 기존 일반 채널 후원 목록 API 동작 영향 없는지 확인
- [x] 수정 파일 기준 정적 진단/테스트/빌드 검증 수행
## 검증 기록
### 1차 구현
- 무엇을: 크리에이터 프로필의 채널 후원 리스트 조회 기간을 월 단위로 제한
- 왜: 기존 기간 계산(`now - 1 month`)은 월 경계 기준 요구사항(해당 월 1일~말일)과 다름
- 어떻게:
- `lsp_diagnostics`(수정 파일 2개) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 2차 수정
- 무엇을: 프로필 집계 응답뿐 아니라 전체 채널 후원 리스트 API도 월 단위(1일~말일) 조회로 통일
- 왜: 요구사항이 프로필 전용이 아닌 전체 채널 후원 리스트 대상까지 확장됨
- 어떻게:
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 3차 수정
- 무엇을: `endDateTime` nullable 분기와 중복 메서드를 제거하고 기존 조회 메서드 시그니처에 `endDateTime`을 포함해 단일 로직으로 정리
- 왜: `endDateTime`이 항상 존재하는 현재 요구사항에서 null 분기 로직은 불필요하며 유지보수 복잡도만 증가시킴
- 어떻게:
- `lsp_diagnostics`(수정 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 4차 수정
- 무엇을: `endDateTime` 도입 이후 테스트 의미를 월 경계 의도에 맞게 보강 (`Service`는 월 시작/종료 전달 검증, `Repository`는 월 범위 기반 필터 검증)
- 왜: 기존 테스트 일부는 단순 파라미터 통과 확인 수준이어서 월 경계 요구사항을 직접 담지 못함
- 어떻게:
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*"` 실행: 성공
- `./gradlew build` 실행: 성공 (ktlint 포함 전체 빌드 통과)
### 5차 수정
- 무엇을: 채널 후원 테스트 2개 파일의 가독성 개선을 위해 `@DisplayName`(한글)과 BDD(`given/when/then`) 단락 설명을 추가
- 왜: 테스트 코드 길이가 길어지며 의도 파악이 어려워져, 시나리오/준비/실행/검증 흐름을 빠르게 읽을 수 있도록 개선 필요
- 어떻게:
- `lsp_diagnostics`(수정 테스트 파일) 실행 시 `.kt` 확장자용 LSP 서버 미구성으로 도구 진단 불가(환경 제약 확인)
- `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.channelDonation.*" && ./gradlew build` 실행: 성공

View File

@@ -395,6 +395,7 @@ class ExplorerService(
limit = 4 limit = 4
) )
// 채널 후원
val channelDonationList = if (isCreator && !isBlock) { val channelDonationList = if (isCreator && !isBlock) {
channelDonationService.getChannelDonationListForProfile( channelDonationService.getChannelDonationListForProfile(
creatorId = creatorId, creatorId = creatorId,
@@ -405,23 +406,6 @@ class ExplorerService(
listOf() listOf()
} }
// 차단한 크리에이터 인지 체크
val activitySummary = if (isCreator) {
// 활동요약 (라이브 횟수, 라이브 시간, 라이브 참여자, 콘텐츠 수)
val liveCount = queryRepository.getLiveCount(creatorId) ?: 0
val liveTime = queryRepository.getLiveTime(creatorId)
val liveContributorCount = queryRepository.getLiveContributorCount(creatorId) ?: 0
val contentCount = queryRepository.getContentCount(creatorId) ?: 0
GetCreatorActivitySummary(
liveCount = liveCount,
liveTime = liveTime,
liveContributorCount = liveContributorCount,
contentCount = contentCount
)
} else {
GetCreatorActivitySummary(0, 0, 0, 0)
}
val seriesList = if (isCreator && !isBlock) { val seriesList = if (isCreator && !isBlock) {
seriesService seriesService
.getSeriesList( .getSeriesList(
@@ -467,7 +451,6 @@ class ExplorerService(
communityPostList = communityPostList, communityPostList = communityPostList,
channelDonationList = channelDonationList, channelDonationList = channelDonationList,
cheers = cheers, cheers = cheers,
activitySummary = activitySummary,
seriesList = seriesList, seriesList = seriesList,
isBlock = isBlock, isBlock = isBlock,
isCreatorRole = isCreator isCreatorRole = isCreator

View File

@@ -18,7 +18,6 @@ data class GetCreatorProfileResponse(
val communityPostList: List<GetCommunityPostListResponse>, val communityPostList: List<GetCommunityPostListResponse>,
val channelDonationList: List<GetChannelDonationListItem>, val channelDonationList: List<GetChannelDonationListItem>,
val cheers: GetCheersResponse, val cheers: GetCheersResponse,
val activitySummary: GetCreatorActivitySummary,
val seriesList: List<GetSeriesListResponse.SeriesListItem>, val seriesList: List<GetSeriesListResponse.SeriesListItem>,
val isBlock: Boolean, val isBlock: Boolean,
val isCreatorRole: Boolean val isCreatorRole: Boolean

View File

@@ -42,6 +42,7 @@ class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFa
useCan.canUsage.eq(CanUsage.DONATION) useCan.canUsage.eq(CanUsage.DONATION)
.or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE)) .or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE))
.or(useCan.canUsage.eq(CanUsage.LIVE)) .or(useCan.canUsage.eq(CanUsage.LIVE))
.or(useCan.canUsage.eq(CanUsage.CHANNEL_DONATION))
) )
.and(buildDateRangeCondition(startDate, endDate)) .and(buildDateRangeCondition(startDate, endDate))
) )
@@ -70,6 +71,7 @@ class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFa
useCan.canUsage.eq(CanUsage.DONATION) useCan.canUsage.eq(CanUsage.DONATION)
.or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE)) .or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE))
.or(useCan.canUsage.eq(CanUsage.LIVE)) .or(useCan.canUsage.eq(CanUsage.LIVE))
.or(useCan.canUsage.eq(CanUsage.CHANNEL_DONATION))
) )
.and(buildDateRangeCondition(startDate, endDate)) .and(buildDateRangeCondition(startDate, endDate))
) )

View File

@@ -17,14 +17,16 @@ interface ChannelDonationMessageQueryRepository {
isCreator: Boolean, isCreator: Boolean,
offset: Long, offset: Long,
limit: Long, limit: Long,
startDateTime: LocalDateTime startDateTime: LocalDateTime,
endDateTime: LocalDateTime
): List<ChannelDonationMessage> ): List<ChannelDonationMessage>
fun getChannelDonationMessageTotalCount( fun getChannelDonationMessageTotalCount(
creatorId: Long, creatorId: Long,
memberId: Long, memberId: Long,
isCreator: Boolean, isCreator: Boolean,
startDateTime: LocalDateTime startDateTime: LocalDateTime,
endDateTime: LocalDateTime
): Int ): Int
} }
@@ -37,13 +39,15 @@ class ChannelDonationMessageQueryRepositoryImpl(
isCreator: Boolean, isCreator: Boolean,
offset: Long, offset: Long,
limit: Long, limit: Long,
startDateTime: LocalDateTime startDateTime: LocalDateTime,
endDateTime: LocalDateTime
): List<ChannelDonationMessage> { ): List<ChannelDonationMessage> {
val where = whereCondition( val where = whereCondition(
creatorId = creatorId, creatorId = creatorId,
memberId = memberId, memberId = memberId,
isCreator = isCreator, isCreator = isCreator,
startDateTime = startDateTime startDateTime = startDateTime,
endDateTime = endDateTime
) )
return queryFactory return queryFactory
@@ -62,13 +66,15 @@ class ChannelDonationMessageQueryRepositoryImpl(
creatorId: Long, creatorId: Long,
memberId: Long, memberId: Long,
isCreator: Boolean, isCreator: Boolean,
startDateTime: LocalDateTime startDateTime: LocalDateTime,
endDateTime: LocalDateTime
): Int { ): Int {
val where = whereCondition( val where = whereCondition(
creatorId = creatorId, creatorId = creatorId,
memberId = memberId, memberId = memberId,
isCreator = isCreator, isCreator = isCreator,
startDateTime = startDateTime startDateTime = startDateTime,
endDateTime = endDateTime
) )
return queryFactory return queryFactory
@@ -83,9 +89,11 @@ class ChannelDonationMessageQueryRepositoryImpl(
creatorId: Long, creatorId: Long,
memberId: Long, memberId: Long,
isCreator: Boolean, isCreator: Boolean,
startDateTime: LocalDateTime startDateTime: LocalDateTime,
endDateTime: LocalDateTime
) = channelDonationMessage.creator.id.eq(creatorId) ) = channelDonationMessage.creator.id.eq(creatorId)
.and(channelDonationMessage.createdAt.goe(startDateTime)) .and(channelDonationMessage.createdAt.goe(startDateTime))
.and(channelDonationMessage.createdAt.lt(endDateTime))
.let { .let {
if (isCreator) { if (isCreator) {
it it

View File

@@ -11,8 +11,9 @@ import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
@Service @Service
class ChannelDonationService( class ChannelDonationService(
@@ -61,14 +62,18 @@ class ChannelDonationService(
memberRepository.findCreatorByIdOrNull(creatorId) memberRepository.findCreatorByIdOrNull(creatorId)
?: throw SodaException(messageKey = "member.validation.creator_not_found") ?: throw SodaException(messageKey = "member.validation.creator_not_found")
val startDateTime = LocalDateTime.now().minusMonths(1) val startDateTime = LocalDate.now()
.with(TemporalAdjusters.firstDayOfMonth())
.atStartOfDay()
val endDateTime = startDateTime.plusMonths(1)
val isCreator = member.role == MemberRole.CREATOR && creatorId == member.id val isCreator = member.role == MemberRole.CREATOR && creatorId == member.id
val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount(
creatorId = creatorId, creatorId = creatorId,
memberId = member.id!!, memberId = member.id!!,
isCreator = isCreator, isCreator = isCreator,
startDateTime = startDateTime startDateTime = startDateTime,
endDateTime = endDateTime
) )
val items = channelDonationMessageRepository.getChannelDonationMessageList( val items = channelDonationMessageRepository.getChannelDonationMessageList(
@@ -77,7 +82,8 @@ class ChannelDonationService(
isCreator = isCreator, isCreator = isCreator,
offset = offset, offset = offset,
limit = limit, limit = limit,
startDateTime = startDateTime startDateTime = startDateTime,
endDateTime = endDateTime
).map { ).map {
GetChannelDonationListItem( GetChannelDonationListItem(
id = it.id!!, id = it.id!!,

View File

@@ -6,13 +6,6 @@ import kr.co.vividnext.sodalive.extensions.getTimeAgoString
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
data class GetLatestFinishedLiveQueryResponse @QueryProjection constructor(
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val updatedAt: LocalDateTime
)
data class GetLatestFinishedLiveResponse( data class GetLatestFinishedLiveResponse(
@JsonProperty("memberId") val memberId: Long, @JsonProperty("memberId") val memberId: Long,
@JsonProperty("nickname") val nickname: String, @JsonProperty("nickname") val nickname: String,
@@ -20,12 +13,18 @@ data class GetLatestFinishedLiveResponse(
@JsonProperty("timeAgo") val timeAgo: String, @JsonProperty("timeAgo") val timeAgo: String,
@JsonProperty("dateUtc") val dateUtc: String @JsonProperty("dateUtc") val dateUtc: String
) { ) {
constructor(response: GetLatestFinishedLiveQueryResponse) : this( @QueryProjection
response.memberId, constructor(
response.nickname, memberId: Long,
response.profileImageUrl, nickname: String,
response.updatedAt.getTimeAgoString(), profileImageUrl: String,
response.updatedAt updatedAt: LocalDateTime
) : this(
memberId,
nickname,
profileImageUrl,
updatedAt.getTimeAgoString(),
updatedAt
.atZone(ZoneId.of("UTC")) .atZone(ZoneId.of("UTC"))
.toInstant() .toInstant()
.toString() .toString()

View File

@@ -66,7 +66,7 @@ interface LiveRoomQueryRepository {
fun getTotalHeartCount(roomId: Long): Int? fun getTotalHeartCount(roomId: Long): Int?
fun getLiveRoomCreatorId(roomId: Long): Long? fun getLiveRoomCreatorId(roomId: Long): Long?
fun getHeartList(roomId: Long): List<GetLiveRoomHeartListItem> fun getHeartList(roomId: Long): List<GetLiveRoomHeartListItem>
fun getLatestFinishedLive(): List<GetLatestFinishedLiveQueryResponse> fun getLatestFinishedLive(memberId: Long?): List<GetLatestFinishedLiveResponse>
} }
class LiveRoomQueryRepositoryImpl( class LiveRoomQueryRepositoryImpl(
@@ -486,10 +486,16 @@ class LiveRoomQueryRepositoryImpl(
.fetch() .fetch()
} }
override fun getLatestFinishedLive(): List<GetLatestFinishedLiveQueryResponse> { override fun getLatestFinishedLive(memberId: Long?): List<GetLatestFinishedLiveResponse> {
return queryFactory var where = liveRoom.isActive.isFalse
.and(liveRoom.channelName.isNotNull)
.and(liveRoom.updatedAt.goe(LocalDateTime.now().minusWeeks(1)))
.and(member.isActive.isTrue)
.and(member.role.eq(MemberRole.CREATOR))
var select = queryFactory
.select( .select(
QGetLatestFinishedLiveQueryResponse( QGetLatestFinishedLiveResponse(
member.id, member.id,
member.nickname, member.nickname,
member.profileImage.prepend("/").prepend(cloudFrontHost), member.profileImage.prepend("/").prepend(cloudFrontHost),
@@ -498,13 +504,24 @@ class LiveRoomQueryRepositoryImpl(
) )
.from(liveRoom) .from(liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.where(
liveRoom.isActive.isFalse if (memberId != null) {
.and(liveRoom.channelName.isNotNull) val blockMemberCondition = blockMember.isActive.isTrue
.and(liveRoom.updatedAt.goe(LocalDateTime.now().minusWeeks(1))) .and(
.and(member.isActive.isTrue) blockMember.member.id.eq(member.id)
.and(member.role.eq(MemberRole.CREATOR)) .and(blockMember.blockedMember.id.eq(memberId))
) .or(
blockMember.member.id.eq(memberId)
.and(blockMember.blockedMember.id.eq(member.id))
)
)
select = select.leftJoin(blockMember).on(blockMemberCondition)
where = where.and(blockMember.id.isNull)
}
return select
.where(where)
.groupBy(member.id) .groupBy(member.id)
.orderBy(liveRoom.updatedAt.max().desc()) .orderBy(liveRoom.updatedAt.max().desc())
.limit(20) .limit(20)

View File

@@ -1444,20 +1444,9 @@ class LiveRoomService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
@Cacheable( @Cacheable(
cacheNames = ["cache_ttl_10_minutes"], cacheNames = ["cache_ttl_10_minutes"],
key = "'getLatestFinishedLive:' + (#member ?: 'guest')" key = "'getLatestFinishedLive:' + (#member?.id ?: 'guest')"
) )
fun getLatestFinishedLive(member: Member?): List<GetLatestFinishedLiveResponse> { fun getLatestFinishedLive(member: Member?): List<GetLatestFinishedLiveResponse> {
return repository.getLatestFinishedLive() return repository.getLatestFinishedLive(memberId = member?.id)
.filter {
if (member?.id != null) {
!blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.memberId) &&
!blockMemberRepository.isBlocked(blockedMemberId = it.memberId, memberId = member.id!!)
} else {
true
}
}
.map {
GetLatestFinishedLiveResponse(response = it)
}
} }
} }

View File

@@ -120,6 +120,7 @@ class MemberService(
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf() private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
private val recommendLiveCacheKeyPrefix = "getRecommendLive:" private val recommendLiveCacheKeyPrefix = "getRecommendLive:"
private val latestFinishedLiveCacheKeyPrefix = "getLatestFinishedLive:"
@Transactional @Transactional
fun signUpV2(request: SignUpRequestV2): SignUpResponse { fun signUpV2(request: SignUpRequestV2): SignUpResponse {
@@ -564,7 +565,11 @@ class MemberService(
} }
evictRecommendLiveCache(memberId) evictRecommendLiveCache(memberId)
blockTargetMemberIds.forEach { evictRecommendLiveCache(it) } evictLatestFinishedLiveCache(memberId)
blockTargetMemberIds.forEach {
evictRecommendLiveCache(it)
evictLatestFinishedLiveCache(it)
}
} }
@Transactional @Transactional
@@ -580,6 +585,8 @@ class MemberService(
evictRecommendLiveCache(memberId) evictRecommendLiveCache(memberId)
evictRecommendLiveCache(request.blockMemberId) evictRecommendLiveCache(request.blockMemberId)
evictLatestFinishedLiveCache(memberId)
evictLatestFinishedLiveCache(request.blockMemberId)
} }
fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId) fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId)
@@ -843,6 +850,10 @@ class MemberService(
cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId) cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId)
} }
private fun evictLatestFinishedLiveCache(memberId: Long) {
cacheManager.getCache("cache_ttl_10_minutes")?.evict(latestFinishedLiveCacheKeyPrefix + memberId)
}
@Transactional @Transactional
fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? { fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? {
val member = repository.findByIdOrNull(id = memberId) val member = repository.findByIdOrNull(id = memberId)

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
@@ -20,44 +21,56 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor(
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@Test @Test
@DisplayName("일반 사용자 조회 시 월 범위와 공개/비공개 규칙을 적용하고 최신순으로 정렬한다")
fun shouldFilterByDateAndSortByCreatedAtAndIdDescForViewer() { fun shouldFilterByDateAndSortByCreatedAtAndIdDescForViewer() {
// given: 크리에이터/조회자/타 사용자 데이터를 준비한다.
val creator = saveMember(nickname = "creator", role = MemberRole.CREATOR) val creator = saveMember(nickname = "creator", role = MemberRole.CREATOR)
val viewer = saveMember(nickname = "viewer", role = MemberRole.USER) val viewer = saveMember(nickname = "viewer", role = MemberRole.USER)
val otherUser = saveMember(nickname = "other", role = MemberRole.USER) val otherUser = saveMember(nickname = "other", role = MemberRole.USER)
// given: 조회 기준 월의 시작/종료 시점을 계산한다.
val now = LocalDateTime.now() val now = LocalDateTime.now()
val tieTime = now.minusDays(2) val monthStart = now.withDayOfMonth(1).toLocalDate().atStartOfDay()
val nextMonthStart = monthStart.plusMonths(1)
val tieTime = monthStart.plusDays(2)
// given: 공개/비공개 및 월 범위 경계 확인용 메시지를 저장한다.
val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false)
val publicTieFirst = saveMessage(member = otherUser, creator = creator, can = 2, isSecret = false) val publicTieFirst = saveMessage(member = otherUser, creator = creator, can = 2, isSecret = false)
val publicTieSecond = saveMessage(member = viewer, creator = creator, can = 3, isSecret = false) val publicTieSecond = saveMessage(member = viewer, creator = creator, can = 3, isSecret = false)
val secretMine = saveMessage(member = viewer, creator = creator, can = 4, isSecret = true) val secretMine = saveMessage(member = viewer, creator = creator, can = 4, isSecret = true)
val secretOther = saveMessage(member = otherUser, creator = creator, can = 5, isSecret = true) val secretOther = saveMessage(member = otherUser, creator = creator, can = 5, isSecret = true)
updateCreatedAt(oldPublic.id!!, now.minusMonths(2)) // given: createdAt을 직접 조정해 정렬/필터 조건을 명확히 만든다.
updateCreatedAt(oldPublic.id!!, monthStart.minusDays(1))
updateCreatedAt(publicTieFirst.id!!, tieTime) updateCreatedAt(publicTieFirst.id!!, tieTime)
updateCreatedAt(publicTieSecond.id!!, tieTime) updateCreatedAt(publicTieSecond.id!!, tieTime)
updateCreatedAt(secretMine.id!!, now.minusDays(1)) updateCreatedAt(secretMine.id!!, monthStart.plusDays(4))
updateCreatedAt(secretOther.id!!, now.minusHours(12)) updateCreatedAt(secretOther.id!!, monthStart.plusDays(5))
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
// when: 일반 사용자 기준으로 목록/총건수 조회를 실행한다.
val list = channelDonationMessageRepository.getChannelDonationMessageList( val list = channelDonationMessageRepository.getChannelDonationMessageList(
creatorId = creator.id!!, creatorId = creator.id!!,
memberId = viewer.id!!, memberId = viewer.id!!,
isCreator = false, isCreator = false,
offset = 0, offset = 0,
limit = 10, limit = 10,
startDateTime = now.minusMonths(1) startDateTime = monthStart,
endDateTime = nextMonthStart
) )
// when: 같은 조건으로 총 건수 조회를 실행한다.
val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount(
creatorId = creator.id!!, creatorId = creator.id!!,
memberId = viewer.id!!, memberId = viewer.id!!,
isCreator = false, isCreator = false,
startDateTime = now.minusMonths(1) startDateTime = monthStart,
endDateTime = nextMonthStart
) )
// then: 비공개 타인 메시지는 제외되고, createdAt desc/id desc 정렬이 적용돼야 한다.
assertEquals(3, list.size) assertEquals(3, list.size)
assertEquals(secretMine.id, list[0].id) assertEquals(secretMine.id, list[0].id)
assertEquals(publicTieSecond.id, list[1].id) assertEquals(publicTieSecond.id, list[1].id)
@@ -66,40 +79,53 @@ class ChannelDonationMessageRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("크리에이터 본인 조회 시 월 범위 내 비공개 메시지를 모두 조회한다")
fun shouldIncludeAllRecentSecretMessagesForCreator() { fun shouldIncludeAllRecentSecretMessagesForCreator() {
// given: 크리에이터/조회자/타 사용자 데이터를 준비한다.
val creator = saveMember(nickname = "creator2", role = MemberRole.CREATOR) val creator = saveMember(nickname = "creator2", role = MemberRole.CREATOR)
val viewer = saveMember(nickname = "viewer2", role = MemberRole.USER) val viewer = saveMember(nickname = "viewer2", role = MemberRole.USER)
val otherUser = saveMember(nickname = "other2", role = MemberRole.USER) val otherUser = saveMember(nickname = "other2", role = MemberRole.USER)
// given: 조회 기준 월의 시작/종료 시점을 계산한다.
val now = LocalDateTime.now() val now = LocalDateTime.now()
val monthStart = now.withDayOfMonth(1).toLocalDate().atStartOfDay()
val nextMonthStart = monthStart.plusMonths(1)
// given: 월 경계/비공개 노출 검증용 메시지를 저장한다.
val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false)
val recentPublic = saveMessage(member = viewer, creator = creator, can = 2, isSecret = false) val recentPublic = saveMessage(member = viewer, creator = creator, can = 2, isSecret = false)
val recentSecretMine = saveMessage(member = viewer, creator = creator, can = 3, isSecret = true) val recentSecretMine = saveMessage(member = viewer, creator = creator, can = 3, isSecret = true)
val recentSecretOther = saveMessage(member = otherUser, creator = creator, can = 4, isSecret = true) val recentSecretOther = saveMessage(member = otherUser, creator = creator, can = 4, isSecret = true)
updateCreatedAt(oldPublic.id!!, now.minusMonths(2)) // given: createdAt을 직접 조정해 월 범위 안/밖 데이터를 분리한다.
updateCreatedAt(recentPublic.id!!, now.minusDays(3)) updateCreatedAt(oldPublic.id!!, monthStart.minusDays(1))
updateCreatedAt(recentSecretMine.id!!, now.minusDays(2)) updateCreatedAt(recentPublic.id!!, monthStart.plusDays(2))
updateCreatedAt(recentSecretOther.id!!, now.minusDays(1)) updateCreatedAt(recentSecretMine.id!!, monthStart.plusDays(3))
updateCreatedAt(recentSecretOther.id!!, monthStart.plusDays(4))
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
// when: 크리에이터 본인 기준으로 목록/총건수 조회를 실행한다.
val list = channelDonationMessageRepository.getChannelDonationMessageList( val list = channelDonationMessageRepository.getChannelDonationMessageList(
creatorId = creator.id!!, creatorId = creator.id!!,
memberId = creator.id!!, memberId = creator.id!!,
isCreator = true, isCreator = true,
offset = 0, offset = 0,
limit = 10, limit = 10,
startDateTime = now.minusMonths(1) startDateTime = monthStart,
endDateTime = nextMonthStart
) )
// when: 같은 조건으로 총 건수 조회를 실행한다.
val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount(
creatorId = creator.id!!, creatorId = creator.id!!,
memberId = creator.id!!, memberId = creator.id!!,
isCreator = true, isCreator = true,
startDateTime = now.minusMonths(1) startDateTime = monthStart,
endDateTime = nextMonthStart
) )
// then: 본인 조회는 비공개 메시지를 포함하고 최신순으로 반환해야 한다.
assertEquals(3, list.size) assertEquals(3, list.size)
assertEquals(recentSecretOther.id, list[0].id) assertEquals(recentSecretOther.id, list[0].id)
assertEquals(recentSecretMine.id, list[1].id) assertEquals(recentSecretMine.id, list[1].id)

View File

@@ -10,9 +10,11 @@ import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime
class ChannelDonationServiceTest { class ChannelDonationServiceTest {
private lateinit var canPaymentService: CanPaymentService private lateinit var canPaymentService: CanPaymentService
@@ -36,7 +38,9 @@ class ChannelDonationServiceTest {
} }
@Test @Test
@DisplayName("후원 캔 수가 1 미만이면 예외를 던진다")
fun shouldThrowWhenDonateCanIsLessThanOne() { fun shouldThrowWhenDonateCanIsLessThanOne() {
// given: 유효하지 않은 후원 요청(캔 0)을 준비한다.
val member = createMember(id = 10L, role = MemberRole.USER, nickname = "viewer") val member = createMember(id = 10L, role = MemberRole.USER, nickname = "viewer")
val request = PostChannelDonationRequest( val request = PostChannelDonationRequest(
creatorId = 1L, creatorId = 1L,
@@ -46,15 +50,19 @@ class ChannelDonationServiceTest {
container = "aos" container = "aos"
) )
// when: 후원 로직을 실행한다.
val exception = assertThrows(SodaException::class.java) { val exception = assertThrows(SodaException::class.java) {
service.donate(request, member) service.donate(request, member)
} }
// then: 최소 캔 수 검증 메시지 키를 반환해야 한다.
assertEquals("content.donation.error.minimum_can", exception.messageKey) assertEquals("content.donation.error.minimum_can", exception.messageKey)
} }
@Test @Test
@DisplayName("일반 사용자 조회 시 비공개 노출 규칙과 월 범위를 적용한다")
fun shouldPassUserVisibilityFlagToRepositoryWhenRequesterIsNotCreator() { fun shouldPassUserVisibilityFlagToRepositoryWhenRequesterIsNotCreator() {
// given: 크리에이터/조회자/후원 메시지 데이터를 준비한다.
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer") val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer")
val message = ChannelDonationMessage(can = 3, isSecret = true, additionalMessage = "응원합니다") val message = ChannelDonationMessage(can = 3, isSecret = true, additionalMessage = "응원합니다")
@@ -62,16 +70,24 @@ class ChannelDonationServiceTest {
message.member = viewer message.member = viewer
message.creator = creator message.creator = creator
message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0) message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
var capturedStartDateTime: LocalDateTime? = null
var capturedEndDateTime: LocalDateTime? = null
// given: repository 응답과 전달 파라미터 캡처를 설정한다.
Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator)
Mockito.`when`( Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageTotalCount( channelDonationMessageRepository.getChannelDonationMessageTotalCount(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(viewer.id!!), Mockito.eq(viewer.id!!),
Mockito.eq(false), Mockito.eq(false),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
).thenReturn(1) ).thenAnswer {
capturedStartDateTime = it.getArgument(3)
capturedEndDateTime = it.getArgument(4)
1
}
Mockito.`when`( Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageList( channelDonationMessageRepository.getChannelDonationMessageList(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
@@ -79,10 +95,12 @@ class ChannelDonationServiceTest {
Mockito.eq(false), Mockito.eq(false),
Mockito.eq(0L), Mockito.eq(0L),
Mockito.eq(5L), Mockito.eq(5L),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
).thenReturn(listOf(message)) ).thenReturn(listOf(message))
// when: 채널 후원 목록 조회를 실행한다.
val result = service.getChannelDonationList( val result = service.getChannelDonationList(
creatorId = creator.id!!, creatorId = creator.id!!,
member = viewer, member = viewer,
@@ -90,14 +108,17 @@ class ChannelDonationServiceTest {
limit = 5 limit = 5
) )
// then: 응답 총 건수/메시지 포맷이 기대값과 일치해야 한다.
assertEquals(1, result.totalCount) assertEquals(1, result.totalCount)
assertEquals(1, result.items.size) assertEquals(1, result.items.size)
assertEquals("3캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message) assertEquals("3캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message)
// then: 일반 사용자 조회(false) 조건으로 repository를 호출해야 한다.
Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount( Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(viewer.id!!), Mockito.eq(viewer.id!!),
Mockito.eq(false), Mockito.eq(false),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList( Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList(
@@ -106,12 +127,20 @@ class ChannelDonationServiceTest {
Mockito.eq(false), Mockito.eq(false),
Mockito.eq(0L), Mockito.eq(0L),
Mockito.eq(5L), Mockito.eq(5L),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
// then: 월 시작/다음 달 시작 범위를 사용해야 한다.
assertEquals(1, capturedStartDateTime!!.dayOfMonth)
assertEquals(LocalTime.MIDNIGHT, capturedStartDateTime!!.toLocalTime())
assertEquals(capturedStartDateTime!!.plusMonths(1), capturedEndDateTime)
} }
@Test @Test
@DisplayName("후원 캔 수는 천 단위 콤마가 포함된 메시지로 포맷된다")
fun shouldFormatCanWithCommaInDonationMessage() { fun shouldFormatCanWithCommaInDonationMessage() {
// given: 1,000캔 후원 메시지 데이터를 준비한다.
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer") val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer")
val message = ChannelDonationMessage(can = 1000, isSecret = true, additionalMessage = "응원합니다") val message = ChannelDonationMessage(can = 1000, isSecret = true, additionalMessage = "응원합니다")
@@ -120,12 +149,14 @@ class ChannelDonationServiceTest {
message.creator = creator message.creator = creator
message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0) message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0)
// given: repository 응답을 설정한다.
Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator)
Mockito.`when`( Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageTotalCount( channelDonationMessageRepository.getChannelDonationMessageTotalCount(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(viewer.id!!), Mockito.eq(viewer.id!!),
Mockito.eq(false), Mockito.eq(false),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
).thenReturn(1) ).thenReturn(1)
@@ -136,10 +167,12 @@ class ChannelDonationServiceTest {
Mockito.eq(false), Mockito.eq(false),
Mockito.eq(0L), Mockito.eq(0L),
Mockito.eq(5L), Mockito.eq(5L),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
).thenReturn(listOf(message)) ).thenReturn(listOf(message))
// when: 목록 조회를 실행한다.
val result = service.getChannelDonationList( val result = service.getChannelDonationList(
creatorId = creator.id!!, creatorId = creator.id!!,
member = viewer, member = viewer,
@@ -147,22 +180,33 @@ class ChannelDonationServiceTest {
limit = 5 limit = 5
) )
// then: 후원 메시지에 천 단위 콤마가 포함되어야 한다.
assertEquals("1,000캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message) assertEquals("1,000캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message)
} }
@Test @Test
@DisplayName("크리에이터 본인 조회 시 creator 플래그와 월 범위를 적용한다")
fun shouldPassCreatorVisibilityFlagToRepositoryWhenRequesterIsCreatorSelf() { fun shouldPassCreatorVisibilityFlagToRepositoryWhenRequesterIsCreatorSelf() {
// given: 조회자와 크리에이터가 동일한 상황을 준비한다.
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
var capturedStartDateTime: LocalDateTime? = null
var capturedEndDateTime: LocalDateTime? = null
// given: repository 응답과 전달 파라미터 캡처를 설정한다.
Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator)
Mockito.`when`( Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageTotalCount( channelDonationMessageRepository.getChannelDonationMessageTotalCount(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(true), Mockito.eq(true),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
).thenReturn(0) ).thenAnswer {
capturedStartDateTime = it.getArgument(3)
capturedEndDateTime = it.getArgument(4)
0
}
Mockito.`when`( Mockito.`when`(
channelDonationMessageRepository.getChannelDonationMessageList( channelDonationMessageRepository.getChannelDonationMessageList(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
@@ -170,10 +214,12 @@ class ChannelDonationServiceTest {
Mockito.eq(true), Mockito.eq(true),
Mockito.eq(0L), Mockito.eq(0L),
Mockito.eq(5L), Mockito.eq(5L),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
).thenReturn(emptyList()) ).thenReturn(emptyList())
// when: 크리에이터 본인으로 목록 조회를 실행한다.
service.getChannelDonationList( service.getChannelDonationList(
creatorId = creator.id!!, creatorId = creator.id!!,
member = creator, member = creator,
@@ -181,10 +227,12 @@ class ChannelDonationServiceTest {
limit = 5 limit = 5
) )
// then: creator(true) 조건으로 repository를 호출해야 한다.
Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount( Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount(
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(creator.id!!), Mockito.eq(creator.id!!),
Mockito.eq(true), Mockito.eq(true),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList( Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList(
@@ -193,8 +241,14 @@ class ChannelDonationServiceTest {
Mockito.eq(true), Mockito.eq(true),
Mockito.eq(0L), Mockito.eq(0L),
Mockito.eq(5L), Mockito.eq(5L),
anyLocalDateTime(),
anyLocalDateTime() anyLocalDateTime()
) )
// then: 월 시작/다음 달 시작 범위를 사용해야 한다.
assertEquals(1, capturedStartDateTime!!.dayOfMonth)
assertEquals(LocalTime.MIDNIGHT, capturedStartDateTime!!.toLocalTime())
assertEquals(capturedStartDateTime!!.plusMonths(1), capturedEndDateTime)
} }
private fun createMember(id: Long, role: MemberRole, nickname: String): Member { private fun createMember(id: Long, role: MemberRole, nickname: String): Member {