diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt index f4536c3a..2edffea0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt @@ -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,14 +100,117 @@ class CreatorRankingSnapshotRefreshService( } } - private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts { - return RefreshLogCounts( - candidateCount = candidates.size, - storedCount = storedCount, - lowScoreExcludedCount = lowScoreExcludedCount + private fun assembleSnapshots( + candidates: List, + utcRange: CreatorRankingUtcRange, + visibleFromAtUtc: java.time.LocalDateTime + ): List { + 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 { 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.toSqlRanks( + metric: (CreatorRankingSnapshotCandidate) -> Long + ): Map { + 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( val candidateCount: Int, val storedCount: Int, @@ -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.takeRankedBoundary(limit: Int): List { - if (size <= limit) return this - val boundaryScore = this[limit - 1].finalScore - return filter { it.finalScore >= boundaryScore } - } - companion object { private const val SNAPSHOT_LIMIT = 20 } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt index 9b15a03a..aa273d4c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt @@ -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(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 = 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 { - 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() - 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 { - return snapshots.filter { - it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc - } - } - - override fun findLatestSnapshots(): List = snapshots - - override fun findPreviousCompletedSnapshots(): List = snapshots - - override fun findLatestVisibleSnapshots( - rankingType: CreatorRankingType, - nowUtc: LocalDateTime - ): List = 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 = 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 ) { - 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) } }