feat(content-ranking): 랭킹 스냅샷 제목 번역을 적용한다

This commit is contained in:
2026-09-09 23:23:19 +09:00
parent e5230c0950
commit 50409e41c0
3 changed files with 247 additions and 17 deletions
@@ -1,8 +1,12 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@@ -10,18 +14,164 @@ 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
import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE",
"spring.jpa.properties.hibernate.generate_statistics=true"
]
)
@Import(QueryDslConfig::class)
class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
private val repository: AudioRankingSnapshotRepository
private val repository: AudioRankingSnapshotRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val entityManager: EntityManager,
private val entityManagerFactory: EntityManagerFactory
) {
private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository)
private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository, contentTranslationRepository)
@Test
@DisplayName("SQL NULL 번역 payload는 snapshot 제목으로 fallback한다")
fun shouldFallbackToSnapshotTitleWhenPersistedTranslationPayloadIsSqlNull() {
val snapshot = repository.save(snapshot(contentId = 1L, title = "snapshot-title", scoreBase = 10.0))
val translationId = saveContentTranslation(snapshot.contentId, "en", "en-title")
entityManager.createNativeQuery("update content_translation set rendered_payload = null where id = :id")
.setParameter("id", translationId)
.executeUpdate()
entityManager.flush()
entityManager.clear()
val records = adapter.findLatestVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
LocalDateTime.of(2026, 6, 8, 23, 59),
"en"
)
assertEquals(listOf("snapshot-title"), records.map { it.title })
}
@Test
@DisplayName("빈 최신과 이전 스냅샷은 번역 조회 없이 빈 목록을 반환한다")
fun shouldSkipTranslationLookupForEmptyLatestAndPreviousSnapshots() {
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
assertEquals(
emptyList<AudioRankingSnapshotRecord>(),
adapter.findLatestVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
LocalDateTime.of(2026, 6, 8, 23, 59),
"en"
)
)
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(
emptyList<AudioRankingSnapshotRecord>(),
adapter.findPreviousVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
LocalDateTime.of(2026, 5, 31, 15, 0),
LocalDateTime.of(2026, 6, 8, 23, 59),
"en"
)
)
assertEquals(2L, statistics.prepareStatementCount)
}
@Test
@DisplayName("랭킹 스냅샷은 요청 locale 번역을 일괄 조회하고 snapshot 제목으로 fallback한다")
fun shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback() {
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val previousStartAt = latestStartAt.minusWeeks(1)
val previousEndAt = latestEndAt.minusWeeks(1)
val contentIds = (1L..5L).toList()
val latestSnapshots = contentIds.mapIndexed { index, contentId ->
snapshot(
contentId = contentId,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt,
rank = index + 1,
scoreBase = 10.0 + index
)
}
val previousSnapshots = contentIds.mapIndexed { index, contentId ->
snapshot(
contentId = contentId,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousEndAt,
visibleFromAtUtc = previousEndAt.plusHours(9),
rank = index + 1,
title = "previous-audio-$contentId",
scoreBase = 20.0 + index
)
}
repository.saveAll(latestSnapshots + previousSnapshots)
saveContentTranslation(1L, "en", "en-title-1")
saveContentTranslation(1L, "ja", "ja-title-1")
saveContentTranslation(2L, "en", "")
saveContentTranslation(2L, "ja", "ja-title-2")
saveContentTranslation(3L, "en", " ")
saveContentTranslation(3L, "ja", "ja-title-3")
saveContentTranslation(4L, "ja", "ja-title-4")
entityManager.flush()
entityManager.clear()
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val nowUtc = latestEndAt.plusDays(1).plusHours(8)
val english = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "en")
val japanese = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "ja")
val englishAgain = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "en")
val previousEnglish = adapter.findPreviousVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
latestStartAt,
nowUtc,
"en"
)
val previousJapanese = adapter.findPreviousVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
latestStartAt,
nowUtc,
"ja"
)
val previousEnglishAgain = adapter.findPreviousVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
latestStartAt,
nowUtc,
"en"
)
assertEquals(listOf("en-title-1", "audio-2", "audio-3", "audio-4", "audio-5"), english.map { it.title })
assertEquals(listOf("ja-title-1", "ja-title-2", "ja-title-3", "ja-title-4", "audio-5"), japanese.map { it.title })
assertEquals(english.map { it.withoutTitle() }, japanese.map { it.withoutTitle() })
assertEquals(english.map { it.withoutTitle() }, englishAgain.map { it.withoutTitle() })
assertEquals(
listOf("en-title-1", "previous-audio-2", "previous-audio-3", "previous-audio-4", "previous-audio-5"),
previousEnglish.map { it.title }
)
assertEquals(
listOf("ja-title-1", "ja-title-2", "ja-title-3", "ja-title-4", "previous-audio-5"),
previousJapanese.map { it.title }
)
assertEquals(previousEnglish.map { it.withoutTitle() }, previousJapanese.map { it.withoutTitle() })
assertEquals(previousEnglish.map { it.withoutTitle() }, previousEnglishAgain.map { it.withoutTitle() })
assertEquals(12L, statistics.prepareStatementCount)
val persistedSnapshots = repository.findAll().sortedBy { it.aggregationStartAtUtc }.groupBy { it.aggregationStartAtUtc }
assertEquals(latestSnapshots.map { it.title }, persistedSnapshots.getValue(latestStartAt).map { it.title })
assertEquals(
latestSnapshots.map { it.scoreValues() },
persistedSnapshots.getValue(latestStartAt).map { it.scoreValues() }
)
assertEquals(previousSnapshots.map { it.title }, persistedSnapshots.getValue(previousStartAt).map { it.title })
assertEquals(
previousSnapshots.map { it.scoreValues() },
persistedSnapshots.getValue(previousStartAt).map { it.scoreValues() }
)
}
@Test
@DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다")
@@ -59,7 +209,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
val snapshots = adapter.findLatestVisibleSnapshots(
rankingType = AudioRankingType.WEEKLY_POPULAR,
nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59)
nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59),
locale = "ko"
)
assertEquals(listOf(3L, 2L), snapshots.map { it.contentId })
@@ -92,7 +243,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
val snapshots = adapter.findLatestVisibleSnapshots(
rankingType = AudioRankingType.WEEKLY_POPULAR,
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59)
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59),
locale = "ko"
)
assertEquals(listOf(1L), snapshots.map { it.contentId })
@@ -153,7 +305,9 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0),
rank: Int = 1,
isAdult: Boolean = false
isAdult: Boolean = false,
title: String = "audio-$contentId",
scoreBase: Double = 100.0
): AudioRankingSnapshot {
return AudioRankingSnapshot(
rankingType = rankingType,
@@ -161,14 +315,31 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
contentId = contentId,
title = "audio-$contentId",
title = title,
creatorMemberId = 100L + contentId,
creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png",
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
isAdult = isAdult,
rank = rank,
finalScore = 100.0
finalScore = scoreBase,
normalizedScore = scoreBase + 0.1,
rawScore = scoreBase + 0.2,
revenueCanAmount = scoreBase.toLong() * 10,
salesCount = scoreBase.toLong() * 20,
viewCount = scoreBase.toLong() * 30,
likeCount = scoreBase.toLong() * 40,
commentCount = scoreBase.toLong() * 50,
previousSalesCount = scoreBase.toLong() * 2,
previousViewCount = scoreBase.toLong() * 3,
previousLikeCount = scoreBase.toLong() * 4,
previousCommentCount = scoreBase.toLong() * 5,
salesGrowthRate = scoreBase + 0.3,
viewGrowthRate = scoreBase + 0.4,
likeGrowthRate = scoreBase + 0.5,
commentGrowthRate = scoreBase + 0.6,
contentGrowthScore = scoreBase + 0.7,
boostMultiplier = scoreBase + 0.8
)
}
@@ -195,4 +366,46 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
finalScore = 100.0
)
}
private fun saveContentTranslation(contentId: Long, locale: String, title: String): Long {
val translation = ContentTranslation(
contentId = contentId,
locale = locale,
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
)
entityManager.persist(translation)
entityManager.flush()
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
entityManager.createNativeQuery(
"update content_translation set rendered_payload = '$payload' format json where id = :id"
)
.setParameter("id", translation.id)
.executeUpdate()
return translation.id!!
}
private fun AudioRankingSnapshotRecord.withoutTitle(): AudioRankingSnapshotRecord = copy(title = "")
private fun AudioRankingSnapshot.scoreValues(): List<Number?> {
return listOf(
finalScore,
normalizedScore,
rawScore,
revenueCanAmount,
salesCount,
viewCount,
likeCount,
commentCount,
previousSalesCount,
previousViewCount,
previousLikeCount,
previousCommentCount,
salesGrowthRate,
viewGrowthRate,
likeGrowthRate,
commentGrowthRate,
contentGrowthScore,
boostMultiplier
)
}
}
@@ -241,13 +241,15 @@ private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort {
override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType,
nowUtc: LocalDateTime
nowUtc: LocalDateTime,
locale: String
): List<AudioRankingSnapshotRecord> = snapshots
override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
nowUtc: LocalDateTime,
locale: String
): List<AudioRankingSnapshotRecord> = snapshots
override fun replaceSnapshots(