feat(ranking): 랭킹 스냅샷 저장소를 추가한다

This commit is contained in:
2026-06-08 15:24:28 +09:00
parent 70cf3b29fa
commit 49f2238b37
5 changed files with 483 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
private val repository: CreatorRankingSnapshotRepository
) {
private val adapter = DefaultCreatorRankingSnapshotRepository(repository)
@Test
@DisplayName("같은 집계 기간의 스냅샷은 삭제 후 새 후보로 교체한다")
fun shouldReplaceSnapshotsByAggregationPeriod() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val oldStartAt = startAt.minusWeeks(1)
val oldEndAt = endAt.minusWeeks(1)
repository.saveAll(
listOf(
snapshot(creatorId = 1L, aggregationStartAtUtc = oldStartAt, aggregationEndAtUtc = oldEndAt),
snapshot(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)
)
)
adapter.replaceSnapshots(
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
)
val allSnapshots = repository.findAll()
assertEquals(listOf(1L, 3L), allSnapshots.map { it.creatorId }.sorted())
assertEquals(listOf(3L), adapter.findLatestSnapshots().map { it.creatorId })
}
@Test
@DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다")
fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() {
val oldStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val oldEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.saveAll(
listOf(
snapshot(
creatorId = 1L,
finalScore = 999.0,
aggregationStartAtUtc = oldStartAt,
aggregationEndAtUtc = oldEndAt
),
snapshot(
creatorId = 2L,
finalScore = 100.0,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt
),
snapshot(
creatorId = 3L,
finalScore = 300.0,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt
),
snapshot(
creatorId = 4L,
finalScore = 200.0,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt
)
)
)
val latestSnapshots = adapter.findLatestSnapshots()
assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.creatorId })
assertEquals(listOf(latestStartAt, latestStartAt, latestStartAt), latestSnapshots.map { it.aggregationStartAtUtc })
assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc })
}
@Test
@DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다")
fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() {
val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0)
val oldestEndAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.saveAll(
listOf(
snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestEndAt),
snapshot(
creatorId = 2L,
finalScore = 200.0,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousEndAt
),
snapshot(
creatorId = 3L,
finalScore = 300.0,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousEndAt
),
snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestEndAt)
)
)
val previousSnapshots = adapter.findPreviousCompletedSnapshots()
assertEquals(listOf(3L, 2L), previousSnapshots.map { it.creatorId })
assertEquals(listOf(previousEndAt, previousEndAt), previousSnapshots.map { it.aggregationEndAtUtc })
}
@Test
@DisplayName("요청한 집계 기간에 스냅샷이 없으면 이전 주차를 대신 반환하지 않는다")
fun shouldReturnEmptyWhenRequestedAggregationPeriodHasNoSnapshots() {
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val requestedStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val requestedEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.save(
snapshot(
creatorId = 1L,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousEndAt
)
)
val snapshots = adapter.findSnapshotsByAggregationPeriod(
aggregationStartAtUtc = requestedStartAt,
aggregationEndAtUtc = requestedEndAt
)
assertEquals(emptyList<CreatorRankingSnapshotRecord>(), snapshots)
}
@Test
@DisplayName("20위 점수 경계 동점 후보는 저장소에서 누락 없이 저장하고 조회할 수 있다")
fun shouldPersistAllCandidatesTiedAtTwentiethScoreBoundary() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val candidates = (1L..19L).map { creatorId ->
snapshotRecord(
creatorId = creatorId,
finalScore = 1000.0 - creatorId,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt
)
} + listOf(
snapshotRecord(creatorId = 20L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt),
snapshotRecord(creatorId = 21L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt),
snapshotRecord(creatorId = 22L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)
)
adapter.replaceSnapshots(startAt, endAt, candidates)
val latestSnapshots = adapter.findLatestSnapshots()
assertEquals(22, latestSnapshots.size)
assertEquals(setOf(20L, 21L, 22L), latestSnapshots.takeLast(3).map { it.creatorId }.toSet())
}
private fun snapshot(
creatorId: Long,
finalScore: Double = 100.0,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime
): CreatorRankingSnapshot {
return CreatorRankingSnapshot(
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore,
contentLiveScore = 10.0,
engagementScore = 20.0,
supportScore = 30.0,
fanLoyaltyScore = 40.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 200,
contentLikeCount = 3,
contentCommentCount = 4,
channelDonationCanAmount = 500,
channelDonationCount = 6,
fanTalkCount = 7,
finalFollowerCount = 8,
followIncrease = -1
)
}
private fun snapshotRecord(
creatorId: Long,
finalScore: Double = 100.0,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime
): CreatorRankingSnapshotRecord {
return CreatorRankingSnapshotRecord(
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore,
contentLiveScore = 10.0,
engagementScore = 20.0,
supportScore = 30.0,
fanLoyaltyScore = 40.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 200,
contentLikeCount = 3,
contentCommentCount = 4,
channelDonationCanAmount = 500,
channelDonationCount = 6,
fanTalkCount = 7,
finalFollowerCount = 8,
followIncrease = -1
)
}
}