test #433

Merged
klaus merged 41 commits from test into main 2026-07-10 07:20:07 +00:00
2 changed files with 75 additions and 649 deletions
Showing only changes of commit 263202f0f5 - Show all commits

View File

@@ -2,19 +2,13 @@ package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
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 java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -23,42 +17,21 @@ import java.time.ZonedDateTime
class CreatorRankingQueryService( class CreatorRankingQueryService(
private val snapshotPort: CreatorRankingSnapshotPort, private val snapshotPort: CreatorRankingSnapshotPort,
private val blockPort: CreatorRankingBlockPort, private val blockPort: CreatorRankingBlockPort,
private val aggregationPort: CreatorRankingAggregationPort,
private val snapshotJobService: CreatorRankingSnapshotJobService, private val snapshotJobService: CreatorRankingSnapshotJobService,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }, private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() },
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val periodPolicy = CreatorRankingPeriodPolicy()
private val scorePolicy = CreatorRankingScorePolicy()
@Transactional(readOnly = true)
fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
return runCatching { return runCatching {
val nowUtc = nowUtc() val nowUtc = nowUtc()
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) val latestSnapshots = findLatestVisibleSnapshots(nowUtc)
val latestItems = latestSnapshots.toRankedItems() val latestItems = latestSnapshots.toRankedItems()
if (latestItems.isEmpty()) { if (latestItems.isEmpty()) {
if (snapshotPort.isSnapshotTableEmpty()) { return@runCatching QueryLogResult(CreatorRankingResult(showRankChange = false, items = emptyList()), 0)
val fallbackItems = aggregateColdStartFallback(nowUtc).toRankedItems()
if (fallbackItems.isNotEmpty()) {
delegateColdStartSnapshotRefresh()
}
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems)
return@runCatching QueryLogResult(
result = CreatorRankingResult(
showRankChange = false,
items = fallbackItems.map { it.maskIfBlocked(blockedCreatorIds) }
),
blockedCreatorCount = blockedCreatorIds.size
)
}
return@runCatching QueryLogResult(
result = CreatorRankingResult(showRankChange = false, items = emptyList()),
blockedCreatorCount = 0
)
} }
val previousItems = snapshotPort.findPreviousVisibleSnapshots( val previousItems = snapshotPort.findPreviousVisibleSnapshots(
@@ -77,10 +50,7 @@ class CreatorRankingQueryService(
).maskIfBlocked(blockedCreatorIds) ).maskIfBlocked(blockedCreatorIds)
} }
QueryLogResult( QueryLogResult(CreatorRankingResult(showRankChange = showRankChange, items = items), blockedCreatorIds.size)
result = CreatorRankingResult(showRankChange = showRankChange, items = items),
blockedCreatorCount = blockedCreatorIds.size
)
}.onSuccess { logResult -> }.onSuccess { logResult ->
log.info( log.info(
"event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}", "event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}",
@@ -99,75 +69,30 @@ class CreatorRankingQueryService(
}.getOrThrow().result }.getOrThrow().result
} }
private fun findLatestVisibleSnapshots(nowUtc: LocalDateTime): List<CreatorRankingSnapshotRecord> {
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc)
if (latestSnapshots.isNotEmpty()) return latestSnapshots
runCatching { snapshotJobService.refreshLastCompletedWeekByFallback() }
.onFailure { ex ->
log.warn("event=creator_ranking_query_fallback_failure error={}", ex.message, ex)
}
return snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc)
}
private data class QueryLogResult( private data class QueryLogResult(
val result: CreatorRankingResult, val result: CreatorRankingResult,
val blockedCreatorCount: Int val blockedCreatorCount: Int
) )
private fun aggregateColdStartFallback(nowUtc: LocalDateTime): List<CreatorRankingSnapshotRecord> {
val startedAt = System.currentTimeMillis()
val period = periodPolicy.resolveLastCompletedWeek(nowProvider())
val utcRange = periodPolicy.toUtcRange(period)
val visibleFromAtUtc = periodPolicy.resolveVisibleFromAtUtc(period.endExclusiveKst)
if (visibleFromAtUtc > nowUtc) {
return emptyList()
}
log.info(
"event=creator_ranking_query_cold_start_fallback_attempt " +
"aggregationStartAtUtc={} aggregationEndAtUtc={}",
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
return runCatching {
aggregationPort.aggregateCandidates(
startInclusiveUtc = utcRange.startInclusiveUtc,
endExclusiveUtc = utcRange.endExclusiveUtc
).map { it.toSnapshotRecord(utcRange) }
}.onSuccess { snapshots ->
log.info(
"event=creator_ranking_query_cold_start_fallback_success " +
"aggregationStartAtUtc={} aggregationEndAtUtc={} itemCount={} elapsedMs={}",
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc,
snapshots.size.coerceAtMost(RANKING_LIMIT),
System.currentTimeMillis() - startedAt
)
}.onFailure { ex ->
log.warn(
"event=creator_ranking_query_cold_start_fallback_failure " +
"aggregationStartAtUtc={} aggregationEndAtUtc={} elapsedMs={} error={}",
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc,
System.currentTimeMillis() - startedAt,
ex.message,
ex
)
}.getOrThrow()
}
private fun delegateColdStartSnapshotRefresh() {
runCatching {
snapshotJobService.ensureLastCompletedWeekSnapshotForColdStart()
}.onFailure { ex ->
log.warn(
"event=creator_ranking_query_cold_start_snapshot_refresh_failure error={}",
ex.message,
ex
)
}
}
private fun nowUtc(): LocalDateTime { private fun nowUtc(): LocalDateTime {
return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime() return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime()
} }
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> { private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
return groupBy { it.finalScore } return sortedBy { it.rankNo }
.toSortedMap(compareByDescending { it })
.values
.flatMap { it.shuffled() }
.take(RANKING_LIMIT) .take(RANKING_LIMIT)
.mapIndexed { index, snapshot -> snapshot.toItem(rank = index + 1) } .map { snapshot -> snapshot.toItem(rank = snapshot.rankNo) }
} }
private fun CreatorRankingSnapshotRecord.toItem(rank: Int): CreatorRankingItem { private fun CreatorRankingSnapshotRecord.toItem(rank: Int): CreatorRankingItem {
@@ -181,70 +106,13 @@ class CreatorRankingQueryService(
) )
} }
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount
)
val calculatedEngagementScore = scorePolicy.calculateEngagementScore(
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount
)
val calculatedSupportScore = scorePolicy.calculateSupportScore(
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount
)
val calculatedFanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
val calculatedFinalScore = scorePolicy.calculateFinalScore(
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore
)
return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = utcRange.endExclusiveUtc.plusHours(9),
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl,
finalScore = calculatedFinalScore,
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
}
private fun findBlockedCreatorIds(viewerMemberId: Long?, items: List<CreatorRankingItem>): Set<Long> { private fun findBlockedCreatorIds(viewerMemberId: Long?, items: List<CreatorRankingItem>): Set<Long> {
if (viewerMemberId == null) { if (viewerMemberId == null) return emptySet()
return emptySet() return blockPort.findBlockedCreatorIds(memberId = viewerMemberId, creatorIds = items.map { it.creatorId })
}
return blockPort.findBlockedCreatorIds(
memberId = viewerMemberId,
creatorIds = items.map { it.creatorId }
)
} }
private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set<Long>): CreatorRankingItem { private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set<Long>): CreatorRankingItem {
if (!blockedCreatorIds.contains(creatorId)) { if (!blockedCreatorIds.contains(creatorId)) return this
return this
}
return copy( return copy(
creatorId = MASKED_CREATOR_ID, creatorId = MASKED_CREATOR_ID,
nickname = MASKED_NICKNAME, nickname = MASKED_NICKNAME,

View File

@@ -1,568 +1,152 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput import org.springframework.transaction.annotation.Transactional
import org.springframework.boot.test.system.OutputCaptureExtension
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingQueryServiceTest { class CreatorRankingQueryServiceTest {
@Test @Test
@DisplayName("스냅샷 후보와 조회 item 내부 모델은 순위 변화와 신규 진입 값을 담을 수 있") @DisplayName("fallback refresh 후 재조회가 같은 read-only transaction에 묶이지 않도록 조회 메서드에는 Transactional이 없")
fun shouldCreateRankingDomainModelsForLaterQueryService() { fun shouldNotWrapQueryMethodWithTransactional() {
val candidate = CreatorRankingSnapshotCandidate( val method = CreatorRankingQueryService::class.java.getDeclaredMethod("getCreatorRankings", java.lang.Long::class.java)
creatorId = 1L,
nickname = "creator",
profileImageUrl = "profile.png",
finalScore = 100.0,
contentLiveScore = 10.0,
engagementScore = 20.0,
supportScore = 30.0,
fanLoyaltyScore = 40.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 200,
contentLikeCount = 3,
contentCommentCount = 4,
channelDonationCanAmount = 500,
channelDonationCount = 6,
fanTalkCount = 7,
finalFollowerCount = 8,
followIncrease = -1
)
val item = CreatorRankingItem(
rank = 1,
rankChange = null,
isNew = true,
creatorId = candidate.creatorId,
nickname = candidate.nickname,
profileImageUrl = candidate.profileImageUrl
)
val fallenItem = item.copy(rank = 2, rankChange = -1, isNew = false)
assertEquals(1L, candidate.creatorId) assertFalse(method.isAnnotationPresent(Transactional::class.java))
assertEquals(100.0, candidate.finalScore, 0.0001)
assertNull(item.rankChange)
assertTrue(item.isNew)
assertEquals(-1, fallenItem.rankChange)
assertFalse(fallenItem.isNew)
} }
@Test @Test
@DisplayName("최신 완료 주차 스냅샷이 없으면 순위 변화 비노출과 빈 목록을 반환한다") @DisplayName("최신 공개 스냅샷이 없으면 fallback refresh 후 재조회한 스냅샷을 응답한다")
fun shouldReturnEmptyResultWhenLatestSnapshotsDoNotExist() { fun shouldRefreshByFallbackAndRequeryWhenLatestSnapshotsEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val service = service(snapshotPort = snapshotPort) val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.latestResponses = ArrayDeque(listOf(emptyList(), listOf(snapshot(1, rankNo = 1))))
val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
val result = service.getCreatorRankings(viewerMemberId = null) val result = service.getCreatorRankings(viewerMemberId = null)
Mockito.verify(jobService).refreshLastCompletedWeekByFallback()
assertEquals(2, snapshotPort.latestCallCount)
assertFalse(result.showRankChange) assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty())
}
@Test
@DisplayName("최신 스냅샷이 있으면 cold-start fallback 집계를 호출하지 않는다")
fun shouldNotUseColdStartFallbackWhenLatestSnapshotsExist() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 2L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(listOf(1L), result.items.map { it.creatorId }) assertEquals(listOf(1L), result.items.map { it.creatorId })
assertEquals(0, aggregationPort.aggregateCallCount)
} }
@Test @Test
@DisplayName("최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 cold-start fallback을 반환한다") @DisplayName("fallback 실패 또는 재조회 결과 없음은 빈 목록 성공으로 반환한다")
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() { fun shouldReturnEmptyWhenFallbackFailsAndRequeryStillEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort() val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) Mockito.doThrow(IllegalStateException("fallback failed")).`when`(jobService).refreshLastCompletedWeekByFallback()
snapshotPort.snapshotTableEmpty = true val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 100),
candidate(creatorId = 2L, liveCanAmount = 200)
)
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(listOf(2L, 1L), result.items.map { it.creatorId })
assertEquals(listOf(1, 2), result.items.map { it.rank })
assertTrue(result.items.all { it.rankChange == null })
assertTrue(result.items.none { it.isNew })
assertEquals(1, aggregationPort.aggregateCallCount)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
Mockito.verify(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@DisplayName("cold-start fallback 후보가 없으면 스냅샷 생성 위임을 호출하지 않는다")
fun shouldNotDelegateColdStartSnapshotRefreshWhenFallbackIsEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
)
val result = service.getCreatorRankings(viewerMemberId = null) val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange) assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty()) assertTrue(result.items.isEmpty())
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() assertEquals(2, snapshotPort.latestCallCount)
} }
@Test @Test
@DisplayName("최신 스냅샷이 없어도 과거 스냅샷 row가 있으면 cold-start fallback을 호출하지 않는") @DisplayName("최신 공개 스냅샷이 있으면 fallback 없이 rankNo 기준 rank/rankChange/isNew를 계산한")
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() { fun shouldUseRankNoForRankChangeWithoutFallback() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort() val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) snapshotPort.latestResponses = ArrayDeque(
snapshotPort.snapshotTableEmpty = false listOf(listOf(snapshot(2, rankNo = 1), snapshot(1, rankNo = 2), snapshot(3, rankNo = 3)))
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
) )
snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))
val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
val result = service.getCreatorRankings(viewerMemberId = null) val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange) Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback()
assertTrue(result.items.isEmpty())
assertEquals(0, aggregationPort.aggregateCallCount)
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@DisplayName("cold-start fallback도 차단 관계가 있으면 크리에이터 식별 정보만 마스킹한다")
fun shouldMaskBlockedCreatorIdentityInColdStartFallback() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
blockPort.blockedCreatorIds = setOf(1L)
val service = service(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort
)
val result = service.getCreatorRankings(viewerMemberId = 99L)
assertEquals(99L, blockPort.memberId)
assertEquals(setOf(1L, 2L), blockPort.creatorIds)
assertEquals(0L, result.items.first().creatorId)
assertEquals("", result.items.first().nickname)
assertEquals("https://cdn.test/profile/default-profile.png", result.items.first().profileImageUrl)
assertEquals(2L, result.items[1].creatorId)
}
@Test
@DisplayName("직전 완료 주차 스냅샷이 없으면 순위 변화 없이 최신 스냅샷 상위 20명을 반환한다")
fun shouldReturnLatestTopTwentyWithoutRankChangeWhenPreviousSnapshotsDoNotExist() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = (1L..21L).map { creatorId ->
snapshot(creatorId = creatorId, finalScore = (100 - creatorId).toDouble())
}
val service = service(snapshotPort = snapshotPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(20, result.items.size)
assertEquals((1..20).toList(), result.items.map { it.rank })
assertEquals((1L..20L).toList(), result.items.map { it.creatorId })
assertTrue(result.items.all { it.rankChange == null })
assertTrue(result.items.none { it.isNew })
}
@Test
@DisplayName("직전 완료 주차 스냅샷이 있으면 현재 순위와 비교해 순위 변화와 신규 진입을 계산한다")
fun shouldCalculateRankChangeAndNewEntryFromPreviousSnapshots() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(
snapshot(creatorId = 2L, finalScore = 300.0),
snapshot(creatorId = 1L, finalScore = 200.0),
snapshot(creatorId = 3L, finalScore = 100.0),
snapshot(creatorId = 4L, finalScore = 50.0)
)
snapshotPort.previousSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 400.0),
snapshot(creatorId = 2L, finalScore = 300.0),
snapshot(creatorId = 3L, finalScore = 100.0)
)
val service = service(snapshotPort = snapshotPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertTrue(result.showRankChange) assertTrue(result.showRankChange)
assertEquals(listOf(2L, 1L, 3L, 4L), result.items.map { it.creatorId }) assertEquals(listOf(2L, 1L, 3L), result.items.map { it.creatorId })
assertEquals(listOf(1, 2, 3, 4), result.items.map { it.rank }) assertEquals(listOf(1, 2, 3), result.items.map { it.rank })
assertEquals(listOf(1, -1, 0, null), result.items.map { it.rankChange }) assertEquals(listOf(1, -1, null), result.items.map { it.rankChange })
assertEquals(listOf(false, false, false, true), result.items.map { it.isNew }) assertEquals(listOf(false, false, true), result.items.map { it.isNew })
} }
@Test @Test
@DisplayName("조회 서비스는 현재 UTC 시각 기준 최신 공개 스냅샷과 직전 공개 스냅샷으로 순위 변화를 계산한다") @DisplayName("차단 관계가 있으면 row를 제거하지 않고 식별 정보만 마스킹한다")
fun shouldUseLatestVisibleSnapshotsAndPreviousVisibleSnapshots() { fun shouldMaskBlockedCreatorWithoutRemovingRow() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val now = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
snapshotPort.latestSnapshots = listOf(
snapshot(creatorId = 2L, finalScore = 300.0),
snapshot(creatorId = 1L, finalScore = 200.0)
)
snapshotPort.previousSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 400.0),
snapshot(creatorId = 2L, finalScore = 100.0)
)
val service = service(snapshotPort = snapshotPort, now = now)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.latestRankingType)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.latestNowUtc)
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.previousRankingType)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.previousCurrentAggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.previousNowUtc)
assertEquals(listOf(1, -1), result.items.map { it.rankChange })
}
@Test
@DisplayName("cold-start fallback은 공개 노출 시각 전이면 원천 집계와 스냅샷 생성 위임을 실행하지 않는다")
fun shouldNotUseColdStartFallbackBeforeVisibleFromAt() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService,
now = ZonedDateTime.of(2026, 6, 8, 8, 59, 59, 0, ZoneId.of("Asia/Seoul"))
)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty())
assertEquals(0, aggregationPort.aggregateCallCount)
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@DisplayName("동점 스냅샷은 같은 점수 구간 안에서만 섞이고 상위 20명만 반환한다")
fun shouldRandomizeOnlyWithinTieGroupsAndLimitToTwentyItems() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 300.0),
snapshot(creatorId = 2L, finalScore = 200.0),
snapshot(creatorId = 3L, finalScore = 200.0)
) + (4L..22L).map { creatorId ->
snapshot(creatorId = creatorId, finalScore = (100 - creatorId).toDouble())
}
val service = service(snapshotPort = snapshotPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(20, result.items.size)
assertEquals(1L, result.items.first().creatorId)
assertEquals(setOf(2L, 3L), result.items.drop(1).take(2).map { it.creatorId }.toSet())
assertEquals((1..20).toList(), result.items.map { it.rank })
}
@Test
@DisplayName("차단 관계가 있으면 순위 row는 유지하고 크리에이터 식별 정보만 마스킹한다")
fun shouldMaskBlockedCreatorIdentityWithoutRemovingRankingRow() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val blockPort = FakeCreatorRankingBlockPort() val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.latestSnapshots = listOf( snapshotPort.latestResponses = ArrayDeque(listOf(listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))))
snapshot(creatorId = 1L, finalScore = 300.0), snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))
snapshot(creatorId = 2L, finalScore = 200.0)
)
snapshotPort.previousSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 300.0),
snapshot(creatorId = 2L, finalScore = 200.0)
)
blockPort.blockedCreatorIds = setOf(2L) blockPort.blockedCreatorIds = setOf(2L)
val service = service(snapshotPort = snapshotPort, blockPort = blockPort) val service = service(snapshotPort = snapshotPort, blockPort = blockPort)
val result = service.getCreatorRankings(viewerMemberId = 99L) val result = service.getCreatorRankings(viewerMemberId = 99L)
assertEquals(listOf(1, 2), result.items.map { it.rank }) assertEquals(listOf(1, 2), result.items.map { it.rank })
assertEquals(99L, blockPort.memberId)
assertEquals(setOf(1L, 2L), blockPort.creatorIds)
assertEquals(2, result.items.size)
assertEquals(0L, result.items[1].creatorId) assertEquals(0L, result.items[1].creatorId)
assertEquals("", result.items[1].nickname) assertEquals("", result.items[1].nickname)
assertEquals("https://cdn.test/profile/default-profile.png", result.items[1].profileImageUrl) assertEquals("https://cdn.test/profile/default-profile.png", result.items[1].profileImageUrl)
assertEquals(0, result.items[1].rankChange)
assertFalse(result.items[1].isNew)
}
@Test
@DisplayName("비회원 조회는 차단 관계를 조회하지 않고 원본 랭킹을 반환한다")
fun shouldNotLookupBlocksForAnonymousViewer() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
val service = service(snapshotPort = snapshotPort, blockPort = blockPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertNull(blockPort.memberId)
assertEquals(1L, result.items.single().creatorId)
assertEquals("creator-1", result.items.single().nickname)
assertEquals("https://cdn.test/profile-1.png", result.items.single().profileImageUrl)
}
@Test
@DisplayName("크리에이터 랭킹 조회 성공은 순위 변화 노출 여부와 반환 수를 로그로 남긴다")
fun shouldLogCreatorRankingQuerySuccessWithResultCounts(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
val service = service(snapshotPort = snapshotPort)
service.getCreatorRankings(viewerMemberId = null)
assertTrue(output.out.contains("event=creator_ranking_query_success"))
assertTrue(output.out.contains("showRankChange=false"))
assertTrue(output.out.contains("itemCount=1"))
assertTrue(output.out.contains("blockedCreatorCount=0"))
}
@Test
@DisplayName("크리에이터 랭킹 조회 실패는 에러를 로그로 남기고 예외를 전파한다")
fun shouldLogCreatorRankingQueryFailureWithError(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestFailure = IllegalStateException("latest snapshots failed")
val service = service(snapshotPort = snapshotPort)
val exception = assertThrows(IllegalStateException::class.java) {
service.getCreatorRankings(viewerMemberId = 99L)
}
assertEquals("latest snapshots failed", exception.message)
assertTrue(output.out.contains("event=creator_ranking_query_failure"))
assertTrue(output.out.contains("error=latest snapshots failed"))
}
@Test
@DisplayName("cold-start fallback 성공은 기간과 반환 수를 로그로 남긴다")
fun shouldLogColdStartFallbackSuccessWithPeriodAndCount(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
service.getCreatorRankings(viewerMemberId = null)
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt"))
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_success"))
assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertTrue(output.out.contains("itemCount=1"))
}
@Test
@DisplayName("cold-start fallback 실패는 기간과 에러를 로그로 남기고 예외를 전파한다")
fun shouldLogColdStartFallbackFailureWithError(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.failure = IllegalStateException("fallback failed")
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val exception = assertThrows(IllegalStateException::class.java) {
service.getCreatorRankings(viewerMemberId = null)
}
assertEquals("fallback failed", exception.message)
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt"))
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_failure"))
assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertTrue(output.out.contains("error=fallback failed"))
}
@Test
@DisplayName("cold-start 스냅샷 생성 위임 실패는 fallback 응답을 깨지 않고 로그로 남긴다")
fun shouldKeepFallbackResponseWhenColdStartSnapshotRefreshDelegationFails(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
Mockito.doThrow(IllegalStateException("cold-start refresh failed"))
.`when`(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart()
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(listOf(1L), result.items.map { it.creatorId })
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_snapshot_refresh_failure"))
assertTrue(output.out.contains("error=cold-start refresh failed"))
} }
private fun service( private fun service(
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(), blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(),
snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java), snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java),
now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
): CreatorRankingQueryService { ) = CreatorRankingQueryService(snapshotPort, blockPort, snapshotJobService, { now }, "https://cdn.test")
return CreatorRankingQueryService(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService,
nowProvider = { now },
cloudFrontHost = "https://cdn.test"
)
}
private fun candidate( private fun snapshot(creatorId: Long, rankNo: Int): CreatorRankingSnapshotRecord {
creatorId: Long,
liveCanAmount: Long = 100
): CreatorRankingSnapshotCandidate {
return CreatorRankingSnapshotCandidate(
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = 0.0,
contentLiveScore = 0.0,
engagementScore = 0.0,
supportScore = 0.0,
fanLoyaltyScore = 0.0,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = 0,
contentLikeCount = 0,
contentCommentCount = 0,
channelDonationCanAmount = 0,
channelDonationCount = 0,
fanTalkCount = 0,
finalFollowerCount = 0,
followIncrease = 0
)
}
private fun snapshot(
creatorId: Long,
finalScore: Double
): CreatorRankingSnapshotRecord {
return CreatorRankingSnapshotRecord( return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0), visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
creatorId = creatorId, creatorId = creatorId,
nickname = "creator-$creatorId", nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png", profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore, rankNo = rankNo,
contentLiveScore = 0.0, finalScore = (100 - rankNo).toDouble(),
engagementScore = 0.0, scorePolicyVersion = "CREATOR_WEEKLY_POPULARITY_V2",
supportScore = 0.0, scoreDetailJson = "{}"
fanLoyaltyScore = 0.0,
liveCanAmount = 0,
contentPurchaseCanAmount = 0,
contentLikeCount = 0,
contentCommentCount = 0,
channelDonationCanAmount = 0,
channelDonationCount = 0,
fanTalkCount = 0,
finalFollowerCount = 0,
followIncrease = 0
) )
} }
} }
private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
var latestSnapshots: List<CreatorRankingSnapshotRecord> = emptyList() var latestResponses: ArrayDeque<List<CreatorRankingSnapshotRecord>> = ArrayDeque(listOf(emptyList()))
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList() var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
var latestFailure: RuntimeException? = null var latestCallCount = 0
var snapshotTableEmpty: Boolean = true
var latestRankingType: CreatorRankingType? = null
var latestNowUtc: LocalDateTime? = null
var previousRankingType: CreatorRankingType? = null
var previousCurrentAggregationStartAtUtc: LocalDateTime? = null
var previousNowUtc: LocalDateTime? = null
override fun findSnapshotsByAggregationPeriod( override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> = emptyList() ) = emptyList<CreatorRankingSnapshotRecord>()
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> { override fun findLatestSnapshots() = latestResponses.firstOrNull() ?: emptyList()
latestFailure?.let { throw it }
return latestSnapshots
}
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots override fun findPreviousCompletedSnapshots() = previousSnapshots
override fun findLatestVisibleSnapshots( override fun findLatestVisibleSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ): List<CreatorRankingSnapshotRecord> {
latestFailure?.let { throw it } latestCallCount++
latestRankingType = rankingType return if (latestResponses.size > 1) {
latestNowUtc = nowUtc latestResponses.removeFirst()
return latestSnapshots } else {
latestResponses.firstOrNull() ?: emptyList()
}
} }
override fun findPreviousVisibleSnapshots( override fun findPreviousVisibleSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
currentAggregationStartAtUtc: LocalDateTime, currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ) = previousSnapshots
previousRankingType = rankingType
previousCurrentAggregationStartAtUtc = currentAggregationStartAtUtc
previousNowUtc = nowUtc
return previousSnapshots
}
override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty
override fun replaceSnapshots( override fun replaceSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -572,33 +156,7 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
) = Unit ) = Unit
} }
private class FakeCreatorRankingQueryAggregationPort : CreatorRankingAggregationPort {
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
var failure: RuntimeException? = null
var aggregateCallCount = 0
var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null
override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
aggregateCallCount++
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it }
return candidates
}
}
private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort { private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort {
var blockedCreatorIds: Set<Long> = emptySet() var blockedCreatorIds: Set<Long> = emptySet()
var memberId: Long? = null override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>) = blockedCreatorIds
var creatorIds: Set<Long> = emptySet()
override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>): Set<Long> {
this.memberId = memberId
this.creatorIds = creatorIds.toSet()
return blockedCreatorIds
}
} }