test #433
@@ -2,7 +2,9 @@ package kr.co.vividnext.sodalive.v2.ranking.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreDetail
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
|
||||
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.CreatorRankingUtcRange
|
||||
@@ -38,9 +40,7 @@ class CreatorRankingSnapshotRefreshService(
|
||||
startInclusiveUtc = utcRange.startInclusiveUtc,
|
||||
endExclusiveUtc = utcRange.endExclusiveUtc
|
||||
)
|
||||
val snapshots = aggregationResult.candidates.map { it.toSnapshotRecord(utcRange) }
|
||||
.sortedByDescending { it.finalScore }
|
||||
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
|
||||
val snapshots = assembleSnapshots(aggregationResult.candidates, utcRange, visibleFromAtUtc)
|
||||
|
||||
snapshotPort.replaceSnapshots(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
@@ -50,7 +50,7 @@ class CreatorRankingSnapshotRefreshService(
|
||||
newSnapshots = snapshots
|
||||
)
|
||||
afterCommit {
|
||||
snapshots.forEachIndexed { index, snapshot ->
|
||||
snapshots.forEach { snapshot ->
|
||||
runCatching {
|
||||
homeFollowingNewsPublishService.publishCreatorRankingVisible(
|
||||
creatorId = snapshot.creatorId,
|
||||
@@ -58,13 +58,13 @@ class CreatorRankingSnapshotRefreshService(
|
||||
creatorProfileImagePath = snapshot.profileImageUrl,
|
||||
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
rank = index + 1
|
||||
rank = snapshot.rankNo
|
||||
)
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
|
||||
snapshot.creatorId,
|
||||
index + 1,
|
||||
snapshot.rankNo,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
@@ -100,12 +100,115 @@ class CreatorRankingSnapshotRefreshService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
|
||||
return RefreshLogCounts(
|
||||
candidateCount = candidates.size,
|
||||
storedCount = storedCount,
|
||||
lowScoreExcludedCount = lowScoreExcludedCount
|
||||
private fun assembleSnapshots(
|
||||
candidates: List<CreatorRankingSnapshotCandidate>,
|
||||
utcRange: CreatorRankingUtcRange,
|
||||
visibleFromAtUtc: java.time.LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
val liveRanks = candidates.toSqlRanks { it.liveCanAmount }
|
||||
val contentRanks = candidates.toSqlRanks { it.contentPurchaseCanAmount }
|
||||
val followRanks = candidates.toSqlRanks { it.followIncrease.coerceAtLeast(0L) }
|
||||
val aiChatRanks = candidates.toSqlRanks { it.aiChatCount }
|
||||
|
||||
return candidates.map { candidate ->
|
||||
val live = metric(
|
||||
candidate.liveCanAmount,
|
||||
liveRanks.getValue(candidate.creatorId),
|
||||
CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT
|
||||
)
|
||||
val content = metric(
|
||||
candidate.contentPurchaseCanAmount,
|
||||
contentRanks.getValue(candidate.creatorId),
|
||||
CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT
|
||||
)
|
||||
val followRaw = scorePolicy.normalizeFollowIncrease(candidate.followIncrease)
|
||||
val follow = metric(
|
||||
followRaw,
|
||||
followRanks.getValue(candidate.creatorId),
|
||||
CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT
|
||||
)
|
||||
val aiChat = metric(
|
||||
candidate.aiChatCount,
|
||||
aiChatRanks.getValue(candidate.creatorId),
|
||||
CreatorRankingScoreSpec.AI_CHAT_WEIGHT
|
||||
)
|
||||
val finalScore = scorePolicy.calculateFinalScore(
|
||||
live.rankScore,
|
||||
content.rankScore,
|
||||
follow.rankScore,
|
||||
aiChat.rankScore
|
||||
)
|
||||
val detail = CreatorRankingScoreDetail(
|
||||
metrics = CreatorRankingScoreDetail.Metrics(
|
||||
liveRevenue = live,
|
||||
contentRevenue = content,
|
||||
followIncrease = follow,
|
||||
aiChat = aiChat
|
||||
),
|
||||
tieBreakers = CreatorRankingScoreDetail.TieBreakers(
|
||||
creatorDebutAt = candidate.creatorDebutAt,
|
||||
creatorId = candidate.creatorId
|
||||
)
|
||||
)
|
||||
ScoredCandidate(candidate, finalScore, detail.toJson())
|
||||
}.filter { it.finalScore > 0.0 }
|
||||
.sortedWith(
|
||||
compareByDescending<ScoredCandidate> { it.finalScore }
|
||||
.thenByDescending { it.candidate.creatorDebutAt ?: java.time.LocalDateTime.MIN }
|
||||
.thenByDescending { it.candidate.creatorId }
|
||||
)
|
||||
.take(SNAPSHOT_LIMIT)
|
||||
.mapIndexed { index, scored ->
|
||||
CreatorRankingSnapshotRecord(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
||||
aggregationEndAtUtc = utcRange.endExclusiveUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
creatorId = scored.candidate.creatorId,
|
||||
nickname = scored.candidate.nickname,
|
||||
profileImageUrl = scored.candidate.profileImageUrl,
|
||||
rankNo = index + 1,
|
||||
finalScore = scored.finalScore,
|
||||
scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
|
||||
scoreDetailJson = scored.scoreDetailJson
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CreatorRankingSnapshotCandidate>.toSqlRanks(
|
||||
metric: (CreatorRankingSnapshotCandidate) -> Long
|
||||
): Map<Long, Int> {
|
||||
var previousValue: Long? = null
|
||||
var currentRank = 0
|
||||
return sortedByDescending(metric).mapIndexed { index, candidate ->
|
||||
val value = metric(candidate)
|
||||
if (previousValue == null || previousValue != value) {
|
||||
currentRank = index + 1
|
||||
previousValue = value
|
||||
}
|
||||
candidate.creatorId to currentRank
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun metric(rawValue: Long, rank: Int, weight: Double): CreatorRankingScoreDetail.Metric {
|
||||
val rankScore = scorePolicy.calculateRankScore(rawValue, rank)
|
||||
return CreatorRankingScoreDetail.Metric(
|
||||
rawValue = rawValue,
|
||||
rank = rank,
|
||||
rankScore = rankScore,
|
||||
weight = weight,
|
||||
weightedScore = rankScore * weight
|
||||
)
|
||||
}
|
||||
|
||||
private data class ScoredCandidate(
|
||||
val candidate: CreatorRankingSnapshotCandidate,
|
||||
val finalScore: Double,
|
||||
val scoreDetailJson: String
|
||||
)
|
||||
|
||||
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
|
||||
return RefreshLogCounts(candidates.size, storedCount, lowScoreExcludedCount)
|
||||
}
|
||||
|
||||
private data class RefreshLogCounts(
|
||||
@@ -132,62 +235,6 @@ class CreatorRankingSnapshotRefreshService(
|
||||
)
|
||||
}
|
||||
|
||||
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 List<CreatorRankingSnapshotRecord>.takeRankedBoundary(limit: Int): List<CreatorRankingSnapshotRecord> {
|
||||
if (size <= limit) return this
|
||||
val boundaryScore = this[limit - 1].finalScore
|
||||
return filter { it.finalScore >= boundaryScore }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SNAPSHOT_LIMIT = 20
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.ranking.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
|
||||
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.port.out.CreatorRankingAggregationPort
|
||||
@@ -8,335 +9,97 @@ import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationRes
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class CreatorRankingSnapshotRefreshServiceTest {
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성은 KST 지난 주를 UTC 조회 기간으로 변환하고 raw 지표 점수를 다시 계산해 저장한다")
|
||||
fun shouldRefreshLastCompletedWeekWithUtcRangeAndCalculatedScores() {
|
||||
@DisplayName("주간 스냅샷 생성은 SQL rank 의미의 V2 점수로 정렬하고 rankNo와 score detail을 저장한다")
|
||||
fun shouldRefreshWithRankBasedV2Score() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
val service = service(aggregationPort, snapshotPort)
|
||||
aggregationPort.candidates = listOf(
|
||||
candidate(
|
||||
creatorId = 1L,
|
||||
finalScore = 1.0,
|
||||
liveCanAmount = 100,
|
||||
contentPurchaseCanAmount = 50,
|
||||
contentLikeCount = 10,
|
||||
contentCommentCount = 4,
|
||||
channelDonationCanAmount = 30,
|
||||
channelDonationCount = 6,
|
||||
fanTalkCount = 3,
|
||||
finalFollowerCount = 20,
|
||||
followIncrease = -2
|
||||
)
|
||||
candidate(1, debut = LocalDateTime.of(2026, 6, 1, 0, 0), live = 100, content = 10, follow = -3, ai = 0),
|
||||
candidate(2, debut = LocalDateTime.of(2026, 6, 2, 0, 0), live = 100, content = 0, follow = 5, ai = 10),
|
||||
candidate(3, debut = null, live = 0, content = 50, follow = 0, ai = 0)
|
||||
)
|
||||
|
||||
service.refreshLastCompletedWeek(now)
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
val stored = snapshotPort.snapshots.single()
|
||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationPort.startInclusiveUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationPort.endExclusiveUtc)
|
||||
assertEquals(aggregationPort.startInclusiveUtc, snapshotPort.aggregationStartAtUtc)
|
||||
assertEquals(aggregationPort.endExclusiveUtc, snapshotPort.aggregationEndAtUtc)
|
||||
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.rankingType)
|
||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
|
||||
assertEquals(85.0, stored.contentLiveScore, 0.0001)
|
||||
assertEquals(7.0, stored.engagementScore, 0.0001)
|
||||
assertEquals(19.8, stored.supportScore, 0.0001)
|
||||
assertEquals(13.4, stored.fanLoyaltyScore, 0.0001)
|
||||
assertEquals(38.14, stored.finalScore, 0.0001)
|
||||
assertEquals(listOf(2L, 1L, 3L), snapshotPort.snapshots.map { it.creatorId })
|
||||
assertEquals(listOf(1, 2, 3), snapshotPort.snapshots.map { it.rankNo })
|
||||
assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, snapshotPort.snapshots.first().scorePolicyVersion)
|
||||
assertTrue(snapshotPort.snapshots.first().scoreDetailJson.contains("\"aiChat\""))
|
||||
assertTrue(snapshotPort.snapshots[1].scoreDetailJson.contains("\"followIncrease\":{\"rawValue\":0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성은 20위 점수 경계와 동점인 후보를 모두 저장하고 더 낮은 점수는 제외한다")
|
||||
fun shouldStoreAllCandidatesTiedAtTwentiethScoreBoundary() {
|
||||
@DisplayName("주간 스냅샷 생성은 최종 점수 0 후보를 제외하고 정확히 상위 20명만 저장한다")
|
||||
fun shouldStoreExactlyTopTwentyPositiveScores() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.candidates = (1L..19L).map { candidate(creatorId = it, liveCanAmount = 1_000 - it) } +
|
||||
candidate(creatorId = 20L, liveCanAmount = 500) +
|
||||
candidate(creatorId = 21L, liveCanAmount = 500) +
|
||||
candidate(creatorId = 22L, liveCanAmount = 500) +
|
||||
candidate(creatorId = 23L, liveCanAmount = 499)
|
||||
val service = service(aggregationPort, snapshotPort)
|
||||
aggregationPort.candidates = (1L..25L).map { candidate(it, live = 1_000 - it) } + candidate(100L)
|
||||
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
assertEquals((1L..22L).toList(), snapshotPort.snapshots.map { it.creatorId })
|
||||
assertEquals(20, snapshotPort.snapshots.size)
|
||||
assertEquals((1..20).toList(), snapshotPort.snapshots.map { it.rankNo })
|
||||
assertEquals((1L..20L).toList(), snapshotPort.snapshots.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성은 같은 집계 기간을 다시 생성할 때 기존 row를 교체한다")
|
||||
fun shouldReplaceSnapshotsForSameAggregationPeriod() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
||||
service.refreshLastCompletedWeek(now)
|
||||
|
||||
aggregationPort.candidates = listOf(candidate(creatorId = 2L, liveCanAmount = 200))
|
||||
service.refreshLastCompletedWeek(now)
|
||||
|
||||
assertEquals(listOf(2L), snapshotPort.snapshots.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성 성공은 집계 기간과 후보/저장 수를 로그로 남긴다")
|
||||
fun shouldLogSnapshotRefreshSuccessWithPeriodAndCounts(output: CapturedOutput) {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.candidates = listOf(
|
||||
candidate(creatorId = 1L, liveCanAmount = 100),
|
||||
candidate(creatorId = 2L, liveCanAmount = 50)
|
||||
)
|
||||
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
|
||||
assertEquals(true, output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
|
||||
assertEquals(true, output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
|
||||
assertEquals(true, output.out.contains("candidateCount=2"))
|
||||
assertEquals(true, output.out.contains("storedCount=2"))
|
||||
assertEquals(true, output.out.contains("lowScoreExcludedCount=0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성 성공 로그는 트랜잭션 커밋 후 기록한다")
|
||||
fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val service = service(aggregationPort = aggregationPort)
|
||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
try {
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
assertEquals(false, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
|
||||
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성 성공은 최종 점수 1점 미만 제외 수를 로그로 남긴다")
|
||||
fun shouldLogLowScoreExcludedCount(output: CapturedOutput) {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val service = service(aggregationPort = aggregationPort)
|
||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
||||
aggregationPort.lowScoreExcludedCount = 2
|
||||
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
assertEquals(true, output.out.contains("candidateCount=1"))
|
||||
assertEquals(true, output.out.contains("lowScoreExcludedCount=2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성 실패는 집계 기간과 에러를 로그로 남기고 예외를 전파한다")
|
||||
fun shouldLogSnapshotRefreshFailureWithPeriodAndError(output: CapturedOutput) {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val service = service(aggregationPort = aggregationPort)
|
||||
aggregationPort.failure = IllegalStateException("aggregate failed")
|
||||
|
||||
val exception = assertThrows(IllegalStateException::class.java) {
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
}
|
||||
|
||||
assertEquals("aggregate failed", exception.message)
|
||||
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_failure"))
|
||||
assertEquals(true, output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
|
||||
assertEquals(true, output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
|
||||
assertEquals(true, output.out.contains("error=aggregate failed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 저장 성공 후 크리에이터 랭킹 최근 소식을 순위와 함께 발행한다")
|
||||
fun shouldPublishCreatorRankingNewsAfterSnapshotsAreReplaced() {
|
||||
@DisplayName("주간 스냅샷 저장 후 홈 팔로잉 최근 소식 발행 순위는 rankNo를 사용한다")
|
||||
fun shouldPublishFollowingNewsWithRankNo() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
val service = service(
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotPort = snapshotPort,
|
||||
publishService = publishService
|
||||
)
|
||||
aggregationPort.candidates = listOf(
|
||||
candidate(creatorId = 1L, liveCanAmount = 200),
|
||||
candidate(creatorId = 2L, liveCanAmount = 100)
|
||||
)
|
||||
val service = service(aggregationPort, snapshotPort, publishService)
|
||||
aggregationPort.candidates = listOf(candidate(1, live = 10), candidate(2, live = 20))
|
||||
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
||||
creatorId = 1L,
|
||||
creatorNickname = "creator-1",
|
||||
creatorProfileImagePath = "profile-1.png",
|
||||
creatorId = 2L,
|
||||
creatorNickname = "creator-2",
|
||||
creatorProfileImagePath = "profile-2.png",
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
rank = 1
|
||||
)
|
||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
||||
creatorId = 2L,
|
||||
creatorNickname = "creator-2",
|
||||
creatorProfileImagePath = "profile-2.png",
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
rank = 2
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 저장 실패 시 크리에이터 랭킹 최근 소식을 발행하지 않는다")
|
||||
fun shouldNotPublishCreatorRankingNewsWhenReplaceSnapshotsFails() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
val service = service(
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotPort = snapshotPort,
|
||||
publishService = publishService
|
||||
)
|
||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
||||
snapshotPort.failure = IllegalStateException("replace failed")
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(publishService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일부 크리에이터 랭킹 최근 소식 발행 실패는 스냅샷 갱신을 실패시키지 않는다")
|
||||
fun shouldNotFailSnapshotRefreshWhenCreatorRankingNewsPublishFails() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
val service = service(
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotPort = snapshotPort,
|
||||
publishService = publishService
|
||||
)
|
||||
aggregationPort.candidates = listOf(
|
||||
candidate(creatorId = 1L, liveCanAmount = 200),
|
||||
candidate(creatorId = 2L, liveCanAmount = 100)
|
||||
)
|
||||
Mockito.doAnswer { invocation ->
|
||||
if (invocation.getArgument<Long>(0) == 1L) {
|
||||
throw IllegalStateException("publish failed")
|
||||
}
|
||||
0
|
||||
}.`when`(publishService)
|
||||
.publishCreatorRankingVisible(
|
||||
creatorId = Mockito.anyLong(),
|
||||
creatorNickname = anyStringValue(),
|
||||
creatorProfileImagePath = Mockito.anyString(),
|
||||
aggregationStartAtUtc = anyLocalDateTime(),
|
||||
visibleFromAtUtc = anyLocalDateTime(),
|
||||
rank = Mockito.anyInt()
|
||||
)
|
||||
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
||||
creatorId = 2L,
|
||||
creatorNickname = "creator-2",
|
||||
creatorProfileImagePath = "profile-2.png",
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
rank = 2
|
||||
)
|
||||
}
|
||||
|
||||
private fun service(
|
||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
|
||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(),
|
||||
aggregationPort: CreatorRankingAggregationPort,
|
||||
snapshotPort: CreatorRankingSnapshotPort,
|
||||
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
): CreatorRankingSnapshotRefreshService {
|
||||
return CreatorRankingSnapshotRefreshService(
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotPort = snapshotPort,
|
||||
homeFollowingNewsPublishService = publishService
|
||||
)
|
||||
}
|
||||
|
||||
private fun anyStringValue(): String {
|
||||
return Mockito.anyString() ?: ""
|
||||
}
|
||||
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
|
||||
}
|
||||
) = CreatorRankingSnapshotRefreshService(aggregationPort, snapshotPort, publishService)
|
||||
|
||||
private fun candidate(
|
||||
creatorId: Long,
|
||||
finalScore: Double = 0.0,
|
||||
liveCanAmount: Long = 0,
|
||||
contentPurchaseCanAmount: Long = 0,
|
||||
contentLikeCount: Long = 0,
|
||||
contentCommentCount: Long = 0,
|
||||
channelDonationCanAmount: Long = 0,
|
||||
channelDonationCount: Long = 0,
|
||||
fanTalkCount: Long = 0,
|
||||
finalFollowerCount: Long = 0,
|
||||
followIncrease: Long = 0
|
||||
): CreatorRankingSnapshotCandidate {
|
||||
return CreatorRankingSnapshotCandidate(
|
||||
creatorId = creatorId,
|
||||
nickname = "creator-$creatorId",
|
||||
profileImageUrl = "profile-$creatorId.png",
|
||||
finalScore = finalScore,
|
||||
contentLiveScore = 0.0,
|
||||
engagementScore = 0.0,
|
||||
supportScore = 0.0,
|
||||
fanLoyaltyScore = 0.0,
|
||||
liveCanAmount = liveCanAmount,
|
||||
contentPurchaseCanAmount = contentPurchaseCanAmount,
|
||||
contentLikeCount = contentLikeCount,
|
||||
contentCommentCount = contentCommentCount,
|
||||
channelDonationCanAmount = channelDonationCanAmount,
|
||||
channelDonationCount = channelDonationCount,
|
||||
fanTalkCount = fanTalkCount,
|
||||
finalFollowerCount = finalFollowerCount,
|
||||
followIncrease = followIncrease
|
||||
)
|
||||
}
|
||||
id: Long,
|
||||
debut: LocalDateTime? = null,
|
||||
live: Long = 0,
|
||||
content: Long = 0,
|
||||
follow: Long = 0,
|
||||
ai: Long = 0
|
||||
) = CreatorRankingSnapshotCandidate(id, "creator-$id", "profile-$id.png", debut, live, content, follow, ai)
|
||||
}
|
||||
|
||||
private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort {
|
||||
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
|
||||
var lowScoreExcludedCount: Int = 0
|
||||
var failure: RuntimeException? = null
|
||||
var startInclusiveUtc: LocalDateTime? = null
|
||||
var endExclusiveUtc: LocalDateTime? = null
|
||||
|
||||
override fun aggregateCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
failure?.let { throw it }
|
||||
return candidates
|
||||
}
|
||||
override fun aggregateCandidates(startInclusiveUtc: LocalDateTime, endExclusiveUtc: LocalDateTime) = candidates
|
||||
|
||||
override fun aggregateCandidateResult(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
@@ -344,48 +107,25 @@ private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort
|
||||
): CreatorRankingAggregationResult {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
failure?.let { throw it }
|
||||
return CreatorRankingAggregationResult(
|
||||
candidates = candidates,
|
||||
lowScoreExcludedCount = lowScoreExcludedCount
|
||||
)
|
||||
return CreatorRankingAggregationResult(candidates, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
|
||||
var rankingType: CreatorRankingType? = null
|
||||
var aggregationStartAtUtc: LocalDateTime? = null
|
||||
var aggregationEndAtUtc: LocalDateTime? = null
|
||||
var visibleFromAtUtc: LocalDateTime? = null
|
||||
var failure: RuntimeException? = null
|
||||
|
||||
override fun findSnapshotsByAggregationPeriod(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
return snapshots.filter {
|
||||
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
|
||||
}
|
||||
}
|
||||
|
||||
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun findLatestVisibleSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
) = snapshots
|
||||
override fun findLatestSnapshots() = snapshots
|
||||
override fun findPreviousCompletedSnapshots() = snapshots
|
||||
override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime) = snapshots
|
||||
override fun findPreviousVisibleSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
currentAggregationStartAtUtc: LocalDateTime,
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun isSnapshotTableEmpty(): Boolean = snapshots.isEmpty()
|
||||
|
||||
) = snapshots
|
||||
override fun replaceSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
@@ -393,14 +133,8 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||
visibleFromAtUtc: LocalDateTime,
|
||||
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||
) {
|
||||
failure?.let { throw it }
|
||||
this.rankingType = rankingType
|
||||
this.aggregationStartAtUtc = aggregationStartAtUtc
|
||||
this.aggregationEndAtUtc = aggregationEndAtUtc
|
||||
this.visibleFromAtUtc = visibleFromAtUtc
|
||||
snapshots.removeIf {
|
||||
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
|
||||
}
|
||||
snapshots.clear()
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user