feat(home): 크리에이터 랭킹 조회 fallback을 재조회로 변경한다
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user