test #433

Merged
klaus merged 41 commits from test into main 2026-07-10 07:20:07 +00:00
2 changed files with 162 additions and 381 deletions
Showing only changes of commit 1f944329f4 - Show all commits

View File

@@ -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.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy 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.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.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.domain.CreatorRankingUtcRange
@@ -38,9 +40,7 @@ class CreatorRankingSnapshotRefreshService(
startInclusiveUtc = utcRange.startInclusiveUtc, startInclusiveUtc = utcRange.startInclusiveUtc,
endExclusiveUtc = utcRange.endExclusiveUtc endExclusiveUtc = utcRange.endExclusiveUtc
) )
val snapshots = aggregationResult.candidates.map { it.toSnapshotRecord(utcRange) } val snapshots = assembleSnapshots(aggregationResult.candidates, utcRange, visibleFromAtUtc)
.sortedByDescending { it.finalScore }
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
@@ -50,7 +50,7 @@ class CreatorRankingSnapshotRefreshService(
newSnapshots = snapshots newSnapshots = snapshots
) )
afterCommit { afterCommit {
snapshots.forEachIndexed { index, snapshot -> snapshots.forEach { snapshot ->
runCatching { runCatching {
homeFollowingNewsPublishService.publishCreatorRankingVisible( homeFollowingNewsPublishService.publishCreatorRankingVisible(
creatorId = snapshot.creatorId, creatorId = snapshot.creatorId,
@@ -58,13 +58,13 @@ class CreatorRankingSnapshotRefreshService(
creatorProfileImagePath = snapshot.profileImageUrl, creatorProfileImagePath = snapshot.profileImageUrl,
aggregationStartAtUtc = utcRange.startInclusiveUtc, aggregationStartAtUtc = utcRange.startInclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
rank = index + 1 rank = snapshot.rankNo
) )
}.onFailure { ex -> }.onFailure { ex ->
log.warn( log.warn(
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}", "event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
snapshot.creatorId, snapshot.creatorId,
index + 1, snapshot.rankNo,
ex.message, ex.message,
ex ex
) )
@@ -100,14 +100,117 @@ class CreatorRankingSnapshotRefreshService(
} }
} }
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts { private fun assembleSnapshots(
return RefreshLogCounts( candidates: List<CreatorRankingSnapshotCandidate>,
candidateCount = candidates.size, utcRange: CreatorRankingUtcRange,
storedCount = storedCount, visibleFromAtUtc: java.time.LocalDateTime
lowScoreExcludedCount = lowScoreExcludedCount ): 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( private data class RefreshLogCounts(
val candidateCount: Int, val candidateCount: Int,
val storedCount: 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<CreatorRankingSnapshotRecord>.takeRankedBoundary(limit: Int): List<CreatorRankingSnapshotRecord> {
if (size <= limit) return this
val boundaryScore = this[limit - 1].finalScore
return filter { it.finalScore >= boundaryScore }
}
companion object { companion object {
private const val SNAPSHOT_LIMIT = 20 private const val SNAPSHOT_LIMIT = 20
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService 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.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.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.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.assertThrows 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.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.support.TransactionSynchronizationManager
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 CreatorRankingSnapshotRefreshServiceTest { class CreatorRankingSnapshotRefreshServiceTest {
@Test @Test
@DisplayName("주간 스냅샷 생성은 KST 지난 주를 UTC 조회 기간으로 변환하고 raw 지표 점수를 다시 계산해 저장한다") @DisplayName("주간 스냅샷 생성은 SQL rank 의미의 V2 점수로 정렬하고 rankNo와 score detail을 저장한다")
fun shouldRefreshLastCompletedWeekWithUtcRangeAndCalculatedScores() { fun shouldRefreshWithRankBasedV2Score() {
val aggregationPort = FakeCreatorRankingAggregationPort() val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort() val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort) val service = service(aggregationPort, snapshotPort)
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
aggregationPort.candidates = listOf( aggregationPort.candidates = listOf(
candidate( candidate(1, debut = LocalDateTime.of(2026, 6, 1, 0, 0), live = 100, content = 10, follow = -3, ai = 0),
creatorId = 1L, candidate(2, debut = LocalDateTime.of(2026, 6, 2, 0, 0), live = 100, content = 0, follow = 5, ai = 10),
finalScore = 1.0, candidate(3, debut = null, live = 0, content = 50, follow = 0, ai = 0)
liveCanAmount = 100,
contentPurchaseCanAmount = 50,
contentLikeCount = 10,
contentCommentCount = 4,
channelDonationCanAmount = 30,
channelDonationCount = 6,
fanTalkCount = 3,
finalFollowerCount = 20,
followIncrease = -2
)
) )
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), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationPort.startInclusiveUtc) assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
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, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc) assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
assertEquals(85.0, stored.contentLiveScore, 0.0001) assertEquals(listOf(2L, 1L, 3L), snapshotPort.snapshots.map { it.creatorId })
assertEquals(7.0, stored.engagementScore, 0.0001) assertEquals(listOf(1, 2, 3), snapshotPort.snapshots.map { it.rankNo })
assertEquals(19.8, stored.supportScore, 0.0001) assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, snapshotPort.snapshots.first().scorePolicyVersion)
assertEquals(13.4, stored.fanLoyaltyScore, 0.0001) assertTrue(snapshotPort.snapshots.first().scoreDetailJson.contains("\"aiChat\""))
assertEquals(38.14, stored.finalScore, 0.0001) assertTrue(snapshotPort.snapshots[1].scoreDetailJson.contains("\"followIncrease\":{\"rawValue\":0"))
} }
@Test @Test
@DisplayName("주간 스냅샷 생성은 20위 점수 경계와 동점인 후보를 모두 저장하고 더 낮은 점수는 제외한다") @DisplayName("주간 스냅샷 생성은 최종 점수 0 후보를 제외하고 정확히 상위 20명만 저장한다")
fun shouldStoreAllCandidatesTiedAtTwentiethScoreBoundary() { fun shouldStoreExactlyTopTwentyPositiveScores() {
val aggregationPort = FakeCreatorRankingAggregationPort() val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort() val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort) val service = service(aggregationPort, snapshotPort)
aggregationPort.candidates = (1L..19L).map { candidate(creatorId = it, liveCanAmount = 1_000 - it) } + aggregationPort.candidates = (1L..25L).map { candidate(it, live = 1_000 - it) } + candidate(100L)
candidate(creatorId = 20L, liveCanAmount = 500) +
candidate(creatorId = 21L, liveCanAmount = 500) +
candidate(creatorId = 22L, liveCanAmount = 500) +
candidate(creatorId = 23L, liveCanAmount = 499)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) 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 @Test
@DisplayName("주간 스냅샷 생성은 같은 집계 기간을 다시 생성할 때 기존 row를 교체한다") @DisplayName("주간 스냅샷 저장 후 홈 팔로잉 최근 소식 발행 순위는 rankNo를 사용한다")
fun shouldReplaceSnapshotsForSameAggregationPeriod() { fun shouldPublishFollowingNewsWithRankNo() {
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() {
val aggregationPort = FakeCreatorRankingAggregationPort() val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort() val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service( val service = service(aggregationPort, snapshotPort, publishService)
aggregationPort = aggregationPort, aggregationPort.candidates = listOf(candidate(1, live = 10), candidate(2, live = 20))
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible( Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 1L, creatorId = 2L,
creatorNickname = "creator-1", creatorNickname = "creator-2",
creatorProfileImagePath = "profile-1.png", creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1 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( private fun service(
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(), aggregationPort: CreatorRankingAggregationPort,
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(), snapshotPort: CreatorRankingSnapshotPort,
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
): CreatorRankingSnapshotRefreshService { ) = CreatorRankingSnapshotRefreshService(aggregationPort, snapshotPort, publishService)
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
}
private fun candidate( private fun candidate(
creatorId: Long, id: Long,
finalScore: Double = 0.0, debut: LocalDateTime? = null,
liveCanAmount: Long = 0, live: Long = 0,
contentPurchaseCanAmount: Long = 0, content: Long = 0,
contentLikeCount: Long = 0, follow: Long = 0,
contentCommentCount: Long = 0, ai: Long = 0
channelDonationCanAmount: Long = 0, ) = CreatorRankingSnapshotCandidate(id, "creator-$id", "profile-$id.png", debut, live, content, follow, ai)
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
)
}
} }
private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort { private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort {
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList() var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
var lowScoreExcludedCount: Int = 0
var failure: RuntimeException? = null
var startInclusiveUtc: LocalDateTime? = null var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null var endExclusiveUtc: LocalDateTime? = null
override fun aggregateCandidates( override fun aggregateCandidates(startInclusiveUtc: LocalDateTime, endExclusiveUtc: LocalDateTime) = candidates
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it }
return candidates
}
override fun aggregateCandidateResult( override fun aggregateCandidateResult(
startInclusiveUtc: LocalDateTime, startInclusiveUtc: LocalDateTime,
@@ -344,48 +107,25 @@ private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort
): CreatorRankingAggregationResult { ): CreatorRankingAggregationResult {
this.startInclusiveUtc = startInclusiveUtc this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it } return CreatorRankingAggregationResult(candidates, 0)
return CreatorRankingAggregationResult(
candidates = candidates,
lowScoreExcludedCount = lowScoreExcludedCount
)
} }
} }
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort { private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>() val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
var rankingType: CreatorRankingType? = null
var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null
var visibleFromAtUtc: LocalDateTime? = null var visibleFromAtUtc: LocalDateTime? = null
var failure: RuntimeException? = null
override fun findSnapshotsByAggregationPeriod( override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ) = snapshots
return snapshots.filter { override fun findLatestSnapshots() = snapshots
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc override fun findPreviousCompletedSnapshots() = snapshots
} override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime) = snapshots
}
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
override fun findLatestVisibleSnapshots(
rankingType: CreatorRankingType,
nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> = snapshots
override fun findPreviousVisibleSnapshots( override fun findPreviousVisibleSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
currentAggregationStartAtUtc: LocalDateTime, currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> = snapshots ) = snapshots
override fun isSnapshotTableEmpty(): Boolean = snapshots.isEmpty()
override fun replaceSnapshots( override fun replaceSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -393,14 +133,8 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
visibleFromAtUtc: LocalDateTime, visibleFromAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) { ) {
failure?.let { throw it }
this.rankingType = rankingType
this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc
this.visibleFromAtUtc = visibleFromAtUtc this.visibleFromAtUtc = visibleFromAtUtc
snapshots.removeIf { snapshots.clear()
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
}
snapshots.addAll(newSnapshots) snapshots.addAll(newSnapshots)
} }
} }