diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt index 76d566e1..e0c95d4c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.ranking.application +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl @@ -18,6 +19,7 @@ import java.time.ZonedDateTime @Service class AudioRankingQueryService( private val snapshotPort: AudioRankingSnapshotPort, + private val langContext: LangContext, private val memberContentPreferenceService: MemberContentPreferenceService, private val blockPort: AudioRankingBlockPort, private val jobService: AudioRankingSnapshotJobService, @@ -29,7 +31,8 @@ class AudioRankingQueryService( fun getRankings(type: AudioRankingType, member: Member?): AudioRanking { val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() - val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc) + val locale = langContext.lang.code + val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc, locale) if (latestSnapshots.isEmpty()) { return AudioRanking(showRankChange = false, type = type, items = emptyList()) } @@ -37,7 +40,8 @@ class AudioRankingQueryService( val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots( rankingType = type, currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc, - nowUtc = nowUtc + nowUtc = nowUtc, + locale = locale ) val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots) val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT) @@ -58,9 +62,10 @@ class AudioRankingQueryService( private fun findLatestVisibleSnapshots( type: AudioRankingType, - nowUtc: java.time.LocalDateTime + nowUtc: java.time.LocalDateTime, + locale: String ): List { - val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc) + val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc, locale) if (latestSnapshots.isNotEmpty()) return latestSnapshots runCatching { jobService.refreshLastCompletedWeekByFallback(type) } @@ -72,7 +77,7 @@ class AudioRankingQueryService( ex ) } - return snapshotPort.findLatestVisibleSnapshots(type, nowUtc) + return snapshotPort.findLatestVisibleSnapshots(type, nowUtc, locale) } private fun canViewAdultContent(member: Member?): Boolean { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt index d1a49280..6c779028 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt @@ -6,13 +6,15 @@ import java.time.LocalDateTime interface AudioRankingSnapshotPort { fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List fun findPreviousVisibleSnapshots( rankingType: AudioRankingType, currentAggregationStartAtUtc: LocalDateTime, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List fun replaceSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt index 47f50290..0e8949f9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web +import kr.co.vividnext.sodalive.content.translation.ContentTranslation +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -29,6 +31,25 @@ class AudioRankingControllerTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager ) { + @Test + @DisplayName("오디오 랭킹 조회는 같은 스냅샷에서 en-ja-en 제목 번역을 격리한다") + fun shouldIsolateRequestedLocaleTitlesWithoutChangingRankingResponse() { + saveSnapshot(contentId = 1L, rank = 1, aggregationStartAtUtc = PREVIOUS_START_AT, aggregationEndAtUtc = PREVIOUS_END_AT) + saveSnapshot(contentId = 2L, rank = 2, aggregationStartAtUtc = PREVIOUS_START_AT, aggregationEndAtUtc = PREVIOUS_END_AT) + saveSnapshot(contentId = 2L, rank = 1, aggregationStartAtUtc = LATEST_START_AT, aggregationEndAtUtc = LATEST_END_AT) + saveSnapshot(contentId = 3L, rank = 2, aggregationStartAtUtc = LATEST_START_AT, aggregationEndAtUtc = LATEST_END_AT) + saveContentTranslation(2L, "en", "en-title-2") + saveContentTranslation(2L, "ja", "ja-title-2") + saveContentTranslation(3L, "en", "en-title-3") + saveContentTranslation(3L, "ja", "ja-title-3") + entityManager.flush() + entityManager.clear() + + expectRisingRanking("en", "en-title-2", "en-title-3") + expectRisingRanking("ja", "ja-title-2", "ja-title-3") + expectRisingRanking("en", "en-title-2", "en-title-3") + } + @Test @DisplayName("오디오 랭킹 조회는 비회원에게 200 OK와 기본 WEEKLY_POPULAR 랭킹을 반환한다") fun shouldReturnWeeklyPopularRankingsForAnonymousByDefault() { @@ -113,6 +134,48 @@ class AudioRankingControllerTest @Autowired constructor( ) } + private fun saveContentTranslation(contentId: Long, locale: String, title: String) { + 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() + } + + private fun expectRisingRanking(locale: String, firstTitle: String, secondTitle: String) { + mockMvc.perform( + get("/api/v2/audio/rankings") + .param("type", "RISING") + .header("Accept-Language", locale) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.showRankChange").value(true)) + .andExpect(jsonPath("$.data.type").value("RISING")) + .andExpect(jsonPath("$.data.items[0].contentId").value(2L)) + .andExpect(jsonPath("$.data.items[0].title").value(firstTitle)) + .andExpect(jsonPath("$.data.items[0].creatorNickname").value("creator-2")) + .andExpect(jsonPath("$.data.items[0].rank").value(1)) + .andExpect(jsonPath("$.data.items[0].rankChange").value(1)) + .andExpect(jsonPath("$.data.items[0].isNew").value(false)) + .andExpect(jsonPath("$.data.items[0].coverImageUrl").value("https://cdn.test/cover-2.png")) + .andExpect(jsonPath("$.data.items[1].contentId").value(3L)) + .andExpect(jsonPath("$.data.items[1].title").value(secondTitle)) + .andExpect(jsonPath("$.data.items[1].creatorNickname").value("creator-3")) + .andExpect(jsonPath("$.data.items[1].rank").value(2)) + .andExpect(jsonPath("$.data.items[1].rankChange").doesNotExist()) + .andExpect(jsonPath("$.data.items[1].isNew").value(true)) + .andExpect(jsonPath("$.data.items[1].coverImageUrl").value("https://cdn.test/cover-3.png")) + } + companion object { private val PREVIOUS_START_AT = LocalDateTime.of(2026, 5, 25, 15, 0) private val PREVIOUS_END_AT = LocalDateTime.of(2026, 6, 1, 15, 0) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt index 48c00c36..6ee488fe 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.v2.content.ranking.application +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType @@ -9,6 +11,7 @@ import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshot import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito @@ -21,6 +24,41 @@ import java.time.ZonedDateTime @ExtendWith(OutputCaptureExtension::class) class AudioRankingQueryServiceTest { + @Test + @DisplayName("랭킹은 요청 locale의 제목만 바꾸고 순위 응답은 유지한다") + fun shouldUseRequestedLocaleTitlesWithoutChangingRankingValues() { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val originalLatestSnapshots = listOf(snapshot(contentId = 2L, rank = 1), snapshot(contentId = 1L, rank = 2)) + snapshotPort.latestSnapshots = originalLatestSnapshots + snapshotPort.previousSnapshots = listOf(snapshot(contentId = 1L, rank = 1), snapshot(contentId = 2L, rank = 2)) + snapshotPort.latestSnapshotsByLocale = mapOf( + "en" to originalLatestSnapshots.map { it.copy(title = "en-title-${it.contentId}") }, + "ja" to originalLatestSnapshots.map { it.copy(title = "ja-title-${it.contentId}") } + ) + val langContext = LangContext().apply { setLang(Lang.EN) } + val service = service(snapshotPort, langContext = langContext) + + val english = service.getRankings(AudioRankingType.REVENUE, member = null) + langContext.setLang(Lang.JA) + val japanese = service.getRankings(AudioRankingType.REVENUE, member = null) + langContext.setLang(Lang.EN) + val englishAgain = service.getRankings(AudioRankingType.REVENUE, member = null) + + assertEquals(listOf("en-title-2", "en-title-1"), english.items.map { it.title }) + assertEquals(listOf("ja-title-2", "ja-title-1"), japanese.items.map { it.title }) + assertEquals(listOf("en-title-2", "en-title-1"), englishAgain.items.map { it.title }) + assertEquals( + english.copy(items = english.items.map { it.copy(title = "") }), + japanese.copy(items = japanese.items.map { it.copy(title = "") }) + ) + assertEquals( + english.copy(items = english.items.map { it.copy(title = "") }), + englishAgain.copy(items = englishAgain.items.map { it.copy(title = "") }) + ) + assertEquals(listOf("en", "ja", "en"), snapshotPort.latestLocales) + assertEquals(listOf("en", "ja", "en"), snapshotPort.previousLocales) + } + @Test fun shouldNotWrapGetRankingsInTransactionSoFallbackRequeryUsesFreshSnapshot() { val method = AudioRankingQueryService::class.java.getDeclaredMethod( @@ -223,7 +261,8 @@ class AudioRankingQueryServiceTest { snapshotPort: FakeAudioRankingQuerySnapshotPort, adultMember: Member? = null, blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(), - jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java), + langContext: LangContext = LangContext() ): AudioRankingQueryService { val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) if (adultMember != null) { @@ -231,6 +270,7 @@ class AudioRankingQueryServiceTest { } return AudioRankingQueryService( snapshotPort = snapshotPort, + langContext = langContext, memberContentPreferenceService = memberContentPreferenceService, blockPort = blockPort, jobService = jobService, @@ -275,25 +315,32 @@ private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort { var nowUtc: LocalDateTime? = null var currentAggregationStartAtUtc: LocalDateTime? = null var latestCallCount: Int = 0 + var latestLocales: MutableList = mutableListOf() + var previousLocales: MutableList = mutableListOf() + var latestSnapshotsByLocale: Map> = emptyMap() override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List { this.nowUtc = nowUtc + latestLocales.add(locale) latestCallCount += 1 if (latestSnapshotsByCall.isNotEmpty()) { return latestSnapshotsByCall.getOrElse(latestCallCount - 1) { latestSnapshotsByCall.last() } } - return latestSnapshots + return latestSnapshotsByLocale[locale] ?: latestSnapshots } override fun findPreviousVisibleSnapshots( rankingType: AudioRankingType, currentAggregationStartAtUtc: LocalDateTime, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List { this.currentAggregationStartAtUtc = currentAggregationStartAtUtc + previousLocales.add(locale) return previousSnapshots }