feat(content-ranking): 랭킹 스냅샷 갱신 서비스를 추가한다
This commit is contained in:
+267
@@ -0,0 +1,267 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class AudioRankingSnapshotRefreshServiceTest {
|
||||
@Test
|
||||
fun shouldStoreTopTwentyByScoreReleaseDateAndContentId() {
|
||||
val aggregationPort = FakeAudioRankingAggregationPort()
|
||||
val snapshotPort = FakeAudioRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.weeklyCandidates = (1L..18L).map { contentId ->
|
||||
candidate(contentId = contentId, salesCount = 100 - contentId, releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0))
|
||||
} + listOf(
|
||||
candidate(contentId = 19L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0)),
|
||||
candidate(contentId = 20L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 3, 0, 0)),
|
||||
candidate(contentId = 21L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 3, 0, 0)),
|
||||
candidate(contentId = 22L, salesCount = 1, releaseDate = LocalDateTime.of(2026, 6, 4, 0, 0))
|
||||
)
|
||||
|
||||
service.refreshLastCompletedWeek(AudioRankingType.WEEKLY_POPULAR, now())
|
||||
|
||||
assertEquals(20, snapshotPort.snapshots.size)
|
||||
assertEquals(listOf(21L, 20L), snapshotPort.snapshots.takeLast(2).map { it.contentId })
|
||||
assertEquals((1..20).toList(), snapshotPort.snapshots.map { it.rank })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldUseLastCompletedWeekUtcRangeAndVisibleFromAt() {
|
||||
val aggregationPort = FakeAudioRankingAggregationPort()
|
||||
val snapshotPort = FakeAudioRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.risingCandidates = listOf(candidate(contentId = 1L, viewCount = 20, previousViewCount = 10))
|
||||
|
||||
service.refreshLastCompletedWeek(AudioRankingType.RISING, now())
|
||||
|
||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
|
||||
assertEquals(AudioRankingType.RISING, snapshotPort.rankingType)
|
||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.aggregationStartAtUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), snapshotPort.aggregationEndAtUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.snapshots.single().visibleFromAtUtc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNormalizeRisingScoresByPaidAndFreeGroups() {
|
||||
val aggregationPort = FakeAudioRankingAggregationPort()
|
||||
val snapshotPort = FakeAudioRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.risingCandidates = listOf(
|
||||
candidate(contentId = 1L, salesCount = 6, previousSalesCount = 3),
|
||||
candidate(contentId = 2L, salesCount = 3, previousSalesCount = 3),
|
||||
candidate(contentId = 3L, viewCount = 20, previousViewCount = 10),
|
||||
candidate(contentId = 4L, viewCount = 10, previousViewCount = 10)
|
||||
)
|
||||
|
||||
service.refreshLastCompletedWeek(AudioRankingType.RISING, now())
|
||||
|
||||
val scoresByContentId = snapshotPort.snapshots.associate { it.contentId to it.finalScore }
|
||||
assertEquals(100.0, scoresByContentId.getValue(1L), 0.0001)
|
||||
assertEquals(0.0, scoresByContentId.getValue(2L), 0.0001)
|
||||
assertEquals(100.0, scoresByContentId.getValue(3L), 0.0001)
|
||||
assertEquals(0.0, scoresByContentId.getValue(4L), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldUseAggregationPortForMetricRankingTypes() {
|
||||
val aggregationPort = FakeAudioRankingAggregationPort()
|
||||
val snapshotPort = FakeAudioRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.revenueCandidates = listOf(candidate(contentId = 1L, finalScore = 10.0))
|
||||
|
||||
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
|
||||
|
||||
assertEquals(AudioRankingType.REVENUE, aggregationPort.metricRankingType)
|
||||
assertEquals(listOf(1L), snapshotPort.snapshots.map { it.contentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldSortMetricTieScoresByReleaseDateAndContentId() {
|
||||
val aggregationPort = FakeAudioRankingAggregationPort()
|
||||
val snapshotPort = FakeAudioRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.revenueCandidates = listOf(
|
||||
candidate(contentId = 1L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0)),
|
||||
candidate(contentId = 2L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0)),
|
||||
candidate(contentId = 3L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0))
|
||||
)
|
||||
|
||||
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
|
||||
|
||||
assertEquals(listOf(3L, 2L, 1L), snapshotPort.snapshots.map { it.contentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldStoreGlobalTopTwentyAndSafeTopTwentyCandidates() {
|
||||
val aggregationPort = FakeAudioRankingAggregationPort()
|
||||
val snapshotPort = FakeAudioRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.revenueCandidates = (1L..20L).map { contentId ->
|
||||
candidate(contentId = contentId, finalScore = (100 - contentId).toDouble(), isAdult = true)
|
||||
} + (21L..40L).map { contentId ->
|
||||
candidate(contentId = contentId, finalScore = (100 - contentId).toDouble(), isAdult = false)
|
||||
}
|
||||
|
||||
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
|
||||
|
||||
assertEquals(40, snapshotPort.snapshots.size)
|
||||
assertEquals((1L..20L).toList(), snapshotPort.snapshots.take(20).map { it.contentId })
|
||||
assertEquals((21L..40L).toList(), snapshotPort.snapshots.drop(20).map { it.contentId })
|
||||
assertEquals((1..40).toList(), snapshotPort.snapshots.map { it.rank })
|
||||
}
|
||||
|
||||
private fun service(
|
||||
aggregationPort: AudioRankingAggregationPort = FakeAudioRankingAggregationPort(),
|
||||
snapshotPort: AudioRankingSnapshotPort = FakeAudioRankingSnapshotPort()
|
||||
): AudioRankingSnapshotRefreshService {
|
||||
return AudioRankingSnapshotRefreshService(
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotPort = snapshotPort
|
||||
)
|
||||
}
|
||||
|
||||
private fun now(): ZonedDateTime {
|
||||
return ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
}
|
||||
|
||||
private fun candidate(
|
||||
contentId: Long,
|
||||
finalScore: Double = 0.0,
|
||||
salesCount: Long = 0,
|
||||
previousSalesCount: Long = 0,
|
||||
viewCount: Long = 0,
|
||||
previousViewCount: Long = 0,
|
||||
releaseDate: LocalDateTime = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||
isAdult: Boolean = false
|
||||
): AudioRankingSnapshotCandidate {
|
||||
return AudioRankingSnapshotCandidate(
|
||||
contentId = contentId,
|
||||
title = "audio-$contentId",
|
||||
creatorMemberId = 100L + contentId,
|
||||
creatorNickname = "creator-$contentId",
|
||||
coverImageUrl = "cover-$contentId.png",
|
||||
releaseDate = releaseDate,
|
||||
isAdult = isAdult,
|
||||
isPaid = salesCount > 0,
|
||||
finalScore = finalScore,
|
||||
salesCount = salesCount,
|
||||
previousSalesCount = previousSalesCount,
|
||||
viewCount = viewCount,
|
||||
previousViewCount = previousViewCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAudioRankingAggregationPort : AudioRankingAggregationPort {
|
||||
var weeklyCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
|
||||
var risingCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
|
||||
var revenueCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
|
||||
var salesCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
|
||||
var commentCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
|
||||
var likeCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
|
||||
var startInclusiveUtc: LocalDateTime? = null
|
||||
var endExclusiveUtc: LocalDateTime? = null
|
||||
var metricRankingType: AudioRankingType? = null
|
||||
|
||||
override fun aggregateWeeklyPopularCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
return weeklyCandidates
|
||||
}
|
||||
|
||||
override fun aggregateRisingCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
return risingCandidates
|
||||
}
|
||||
|
||||
override fun aggregateRevenueCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
metricRankingType = AudioRankingType.REVENUE
|
||||
return revenueCandidates
|
||||
}
|
||||
|
||||
override fun aggregateSalesCountCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
metricRankingType = AudioRankingType.SALES_COUNT
|
||||
return salesCountCandidates
|
||||
}
|
||||
|
||||
override fun aggregateCommentCountCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
metricRankingType = AudioRankingType.COMMENT_COUNT
|
||||
return commentCountCandidates
|
||||
}
|
||||
|
||||
override fun aggregateLikeCountCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
metricRankingType = AudioRankingType.LIKE_COUNT
|
||||
return likeCountCandidates
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort {
|
||||
val snapshots = mutableListOf<AudioRankingSnapshotRecord>()
|
||||
var rankingType: AudioRankingType? = null
|
||||
var aggregationStartAtUtc: LocalDateTime? = null
|
||||
var aggregationEndAtUtc: LocalDateTime? = null
|
||||
var visibleFromAtUtc: LocalDateTime? = null
|
||||
|
||||
override fun findLatestVisibleSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
nowUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun findPreviousVisibleSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
currentAggregationStartAtUtc: LocalDateTime,
|
||||
nowUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun replaceSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
visibleFromAtUtc: LocalDateTime,
|
||||
newSnapshots: List<AudioRankingSnapshotRecord>
|
||||
) {
|
||||
this.rankingType = rankingType
|
||||
this.aggregationStartAtUtc = aggregationStartAtUtc
|
||||
this.aggregationEndAtUtc = aggregationEndAtUtc
|
||||
this.visibleFromAtUtc = visibleFromAtUtc
|
||||
snapshots.clear()
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user