test #449
+22
-7
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||
|
||||
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.AudioRankingSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||
@@ -9,25 +10,28 @@ import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class DefaultAudioRankingSnapshotPersistenceAdapter(
|
||||
private val repository: AudioRankingSnapshotRepository
|
||||
private val repository: AudioRankingSnapshotRepository,
|
||||
private val contentTranslationRepository: ContentTranslationRepository
|
||||
) : AudioRankingSnapshotPort {
|
||||
override fun findLatestVisibleSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
nowUtc: LocalDateTime
|
||||
nowUtc: LocalDateTime,
|
||||
locale: String
|
||||
): List<AudioRankingSnapshotRecord> {
|
||||
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() }
|
||||
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).toRecords(locale)
|
||||
}
|
||||
|
||||
override fun findPreviousVisibleSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
currentAggregationStartAtUtc: LocalDateTime,
|
||||
nowUtc: LocalDateTime
|
||||
nowUtc: LocalDateTime,
|
||||
locale: String
|
||||
): List<AudioRankingSnapshotRecord> {
|
||||
return repository.findPreviousVisibleSnapshots(
|
||||
rankingType = rankingType.name,
|
||||
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
||||
nowUtc = nowUtc
|
||||
).map { it.toRecord() }
|
||||
).toRecords(locale)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -46,14 +50,25 @@ class DefaultAudioRankingSnapshotPersistenceAdapter(
|
||||
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
|
||||
}
|
||||
|
||||
private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord {
|
||||
private fun List<AudioRankingSnapshot>.toRecords(locale: String): List<AudioRankingSnapshotRecord> {
|
||||
if (isEmpty()) return emptyList()
|
||||
val translatedTitleByContentId = contentTranslationRepository.findByContentIdInAndLocale(
|
||||
map { it.contentId },
|
||||
locale
|
||||
).mapNotNull { translation ->
|
||||
translation.renderedPayload.title.takeIf { it.isNotBlank() }?.let { translation.contentId to it }
|
||||
}.toMap()
|
||||
return map { snapshot -> snapshot.toRecord(translatedTitleByContentId[snapshot.contentId] ?: snapshot.title) }
|
||||
}
|
||||
|
||||
private fun AudioRankingSnapshot.toRecord(displayTitle: String = title): AudioRankingSnapshotRecord {
|
||||
return AudioRankingSnapshotRecord(
|
||||
rankingType = rankingType,
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
contentId = contentId,
|
||||
title = title,
|
||||
title = displayTitle,
|
||||
creatorMemberId = creatorMemberId,
|
||||
creatorNickname = creatorNickname,
|
||||
coverImageUrl = coverImageUrl,
|
||||
|
||||
+221
-8
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user