test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
4 changed files with 319 additions and 23 deletions
Showing only changes of commit dba70483ad - Show all commits
@@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.event.EventItem
@@ -38,6 +39,7 @@ import javax.persistence.EntityManager
class DefaultAudioRecommendationQueryRepository( class DefaultAudioRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) : AudioRecommendationQueryRepository { ) : AudioRecommendationQueryRepository {
@@ -115,32 +117,35 @@ class DefaultAudioRecommendationQueryRepository(
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime now: LocalDateTime,
locale: String
): List<AudioCard> { ): List<AudioCard> {
val rows = audioRows(memberId, canViewAdultContent, now) { val rows = audioRows(memberId, canViewAdultContent, now) {
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong()) orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
} }
return rows.toAudioCards(now, canViewAdultContent) return rows.toAudioCards(now, canViewAdultContent, locale)
} }
override fun findFreeAudios( override fun findFreeAudios(
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime now: LocalDateTime,
locale: String
): List<AudioCard> { ): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) { val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong()) orderBy(randomTieBreaker.asc()).limit(limit.toLong())
} }
return rows.toAudioCards(now, canViewAdultContent) return rows.toAudioCards(now, canViewAdultContent, locale)
} }
override fun findPointAudios( override fun findPointAudios(
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime now: LocalDateTime,
locale: String
): List<AudioCard> { ): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows( val rows = audioRows(
@@ -151,25 +156,27 @@ class DefaultAudioRecommendationQueryRepository(
) { ) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong()) orderBy(randomTieBreaker.asc()).limit(limit.toLong())
} }
return rows.toAudioCards(now, canViewAdultContent) return rows.toAudioCards(now, canViewAdultContent, locale)
} }
override fun findAudioCardsByIds( override fun findAudioCardsByIds(
contentIds: List<Long>, contentIds: List<Long>,
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime now: LocalDateTime,
locale: String
): List<AudioCard> { ): List<AudioCard> {
if (contentIds.isEmpty()) return emptyList() if (contentIds.isEmpty()) return emptyList()
val orderById = contentIds.withIndex().associate { it.value to it.index } val orderById = contentIds.withIndex().associate { it.value to it.index }
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this } val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE } return rows.toAudioCards(now, canViewAdultContent, locale).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
} }
override fun findCommentedAudiosByIds( override fun findCommentedAudiosByIds(
contentIds: List<Long>, contentIds: List<Long>,
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean canViewAdultContent: Boolean,
locale: String
): List<CommentedAudio> { ): List<CommentedAudio> {
if (contentIds.isEmpty()) return emptyList() if (contentIds.isEmpty()) return emptyList()
val contentOrder = contentIds.withIndex().associate { it.value to it.index } val contentOrder = contentIds.withIndex().associate { it.value to it.index }
@@ -235,7 +242,7 @@ class DefaultAudioRecommendationQueryRepository(
) )
) )
""".trimIndent() """.trimIndent()
return entityManager.createNativeQuery(sql) val commentedAudios = entityManager.createNativeQuery(sql)
.setParameter("contentIds", contentIds) .setParameter("contentIds", contentIds)
.setParameter("memberId", memberId) .setParameter("memberId", memberId)
.setParameter("canViewAdultContent", canViewAdultContent) .setParameter("canViewAdultContent", canViewAdultContent)
@@ -251,6 +258,11 @@ class DefaultAudioRecommendationQueryRepository(
?: "$cloudFrontHost/profile/default-profile.png" ?: "$cloudFrontHost/profile/default-profile.png"
) )
} }
val translatedTitleByContentId = translatedTitles(commentedAudios.map { it.audioContentId }, locale)
return commentedAudios
.map { audio ->
translatedTitleByContentId[audio.audioContentId]?.let { audio.copy(title = it) } ?: audio
}
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE } .sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
} }
@@ -455,18 +467,23 @@ class DefaultAudioRecommendationQueryRepository(
.fetch() .fetch()
} }
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> { private fun List<Tuple>.toAudioCards(
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<AudioCard> {
if (isEmpty()) return emptyList() if (isEmpty()) return emptyList()
val contentIds = map { it.get(audioContent.id)!! } val contentIds = map { it.get(audioContent.id)!! }
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct() val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent) val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds) val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
val translatedTitleByContentId = translatedTitles(contentIds, locale)
return map { row -> return map { row ->
val contentId = row.get(audioContent.id)!! val contentId = row.get(audioContent.id)!!
val creatorId = row.get(audioContent.member.id)!! val creatorId = row.get(audioContent.member.id)!!
AudioCard( AudioCard(
audioContentId = contentId, audioContentId = contentId,
title = row.get(audioContent.title)!!, title = translatedTitleByContentId[contentId] ?: row.get(audioContent.title)!!,
duration = row.get(audioContent.duration), duration = row.get(audioContent.duration),
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost), imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
price = row.get(audioContent.price)!!, price = row.get(audioContent.price)!!,
@@ -479,6 +496,17 @@ class DefaultAudioRecommendationQueryRepository(
} }
} }
private fun translatedTitles(contentIds: List<Long>, locale: String): Map<Long, String> {
if (contentIds.isEmpty()) return emptyMap()
return contentTranslationRepository.findByContentIdInAndLocale(contentIds, locale)
.mapNotNull { translation ->
translation.renderedPayload.title
.takeIf { it.isNotBlank() }
?.let { translation.contentId to it }
}
.toMap()
}
private fun firstAudioContentIds( private fun firstAudioContentIds(
creatorIds: List<Long>, creatorIds: List<Long>,
now: LocalDateTime, now: LocalDateTime,
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
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.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
@@ -93,6 +95,44 @@ class ContentOverviewEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.hasNext").value(false))
} }
@Test
@DisplayName("콘텐츠 전체보기 New & Hot은 같은 요청에서 en-ja-en 제목 번역을 격리한다")
fun shouldIsolateRequestedLocaleTitlesForNewAndHotAudioOverview() {
val fixture = createNewAndHotFixture("content-overview-new-hot")
saveContentTranslation(fixture.audioContentId, "en", "content-overview-new-hot-en-translated")
saveContentTranslation(fixture.audioContentId, "ja", "content-overview-new-hot-ja-translated")
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "en", "content-overview-new-hot-en-translated")
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "ja", "content-overview-new-hot-ja-translated")
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "en", "content-overview-new-hot-en-translated")
}
@Test
@DisplayName("콘텐츠 전체보기 첫 오디오는 같은 요청에서 en-ja-en 제목 번역을 격리한다")
fun shouldIsolateRequestedLocaleTitlesForFirstAudioContentOverview() {
val fixture = createFirstAudioFixture("content-overview-first")
saveContentTranslation(fixture.audioContentId, "en", "content-overview-first-en-translated")
saveContentTranslation(fixture.audioContentId, "ja", "content-overview-first-ja-translated")
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "en", "content-overview-first-en-translated")
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "ja", "content-overview-first-ja-translated")
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "en", "content-overview-first-en-translated")
}
private fun expectOverviewTitle(fixture: Fixture, type: String, locale: String, title: String) {
mockMvc.perform(
get("/api/v2/contents")
.param("type", type)
.param("page", "0")
.param("size", "20")
.header("Accept-Language", locale)
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.items[0].title").value(title))
}
@Test @Test
@DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다") @DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다")
fun shouldFallbackInvalidTypeToNewAndHotAudio() { fun shouldFallbackInvalidTypeToNewAndHotAudio() {
@@ -197,6 +237,24 @@ class ContentOverviewEndToEndTest @Autowired constructor(
) )
} }
private fun saveContentTranslation(contentId: Long, locale: String, title: String) {
transactionTemplate.execute {
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 data class Fixture( private data class Fixture(
val viewer: Member, val viewer: Member,
val audioContentId: Long val audioContentId: Long
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
@@ -54,9 +56,11 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true)) .andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.recommendedAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId)) .andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.pointAudios.length()").value(1)) .andExpect(
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId)) jsonPath(
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100)) "$.data.pointAudios[?(@.audioContentId == ${fixture.paidPointAudioContentId} && @.price == 100)]"
).isNotEmpty
)
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment")) .andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
.andExpect( .andExpect(
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl") jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
@@ -64,6 +68,51 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
) )
} }
@Test
@DisplayName("오디오 추천 API는 같은 요청에서 en-ja-en 제목 번역을 격리한다")
fun shouldIsolateRequestedLocaleTitlesForAllRecommendationAudioCards() {
val fixture = createFixture()
transactionTemplate.execute {
saveContentTranslation(fixture.audioContentId, "en", "audio-recommendation-e2e-en")
saveContentTranslation(fixture.audioContentId, "ja", "audio-recommendation-e2e-ja")
saveContentTranslation(fixture.paidPointAudioContentId, "en", "paid-point-en")
saveContentTranslation(fixture.paidPointAudioContentId, "ja", "paid-point-ja")
entityManager.flush()
}
expectRecommendationTitles(fixture, "en", "audio-recommendation-e2e-en", "paid-point-en")
expectRecommendationTitles(fixture, "ja", "audio-recommendation-e2e-ja", "paid-point-ja")
expectRecommendationTitles(fixture, "en", "audio-recommendation-e2e-en", "paid-point-en")
}
private fun expectRecommendationTitles(
fixture: Fixture,
locale: String,
audioTitle: String,
pointAudioTitle: String
) {
mockMvc.perform(get("/api/v2/audio/recommendations").header("Accept-Language", locale))
.andExpect(status().isOk)
.andExpect(
jsonPath(translatedTitlePath("latestAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("newAndHotAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("freeAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("pointAudios", fixture.paidPointAudioContentId, pointAudioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("mostCommentedAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("recommendedAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
}
private fun createFixture(): Fixture { private fun createFixture(): Fixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1) val now = LocalDateTime.now().minusHours(1)
@@ -183,6 +232,26 @@ class AudioRecommendationEndToEndTest @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 translatedTitlePath(collection: String, contentId: Long, title: String): String {
return "$.data.$collection[?(@.audioContentId == $contentId && @.title == '$title')]"
}
private data class Fixture( private data class Fixture(
val seriesId: Long, val seriesId: Long,
val audioContentId: Long, val audioContentId: Long,
@@ -10,6 +10,9 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
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.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
@@ -38,9 +41,15 @@ import javax.persistence.EntityManager
@Import(QueryDslConfig::class) @Import(QueryDslConfig::class)
class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager, private val entityManager: EntityManager,
queryFactory: JPAQueryFactory queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository
) { ) {
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test") private val repository = DefaultAudioRecommendationQueryRepository(
queryFactory,
entityManager,
contentTranslationRepository,
"https://cdn.test"
)
@Test @Test
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다") @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
@@ -210,9 +219,27 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
} }
flushAndClear() flushAndClear()
val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now) val latestAudios = repository.findLatestAudios(
val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now) 12,
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now) viewer.id,
canViewAdultContent = false,
now = now,
locale = Lang.KO.code
)
val freeAudios = repository.findFreeAudios(
10,
viewer.id,
canViewAdultContent = false,
now = now,
locale = Lang.KO.code
)
val pointAudios = repository.findPointAudios(
10,
viewer.id,
canViewAdultContent = false,
now = now,
locale = Lang.KO.code
)
assertEquals(12, latestAudios.size) assertEquals(12, latestAudios.size)
assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId }) assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
@@ -297,7 +324,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val commented = repository.findCommentedAudiosByIds( val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(first.id!!, second.id!!), contentIds = listOf(first.id!!, second.id!!),
memberId = viewer.id, memberId = viewer.id,
canViewAdultContent = false canViewAdultContent = false,
locale = Lang.KO.code
) )
assertEquals(listOf(first.id), snapshots.map { it.targetId }) assertEquals(listOf(first.id), snapshots.map { it.targetId })
@@ -308,6 +336,101 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl) assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
} }
@Test
@DisplayName("오디오 카드 공통 QueryDSL 경로는 빈 문자열, 공백, 누락, 다른 언어 번역을 원문으로 대체한다")
fun shouldTranslateAudioCardsByRequestedLocaleWithFallbackAndInputOrder() {
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
val creator = saveMember("translated-card-creator", MemberRole.CREATOR)
val theme = saveTheme()
val translated = saveAudio(creator, theme, "translated-source", now.minusDays(1))
val empty = saveAudio(creator, theme, "empty-source", now.minusDays(2))
val whitespace = saveAudio(creator, theme, "whitespace-source", now.minusDays(3))
val otherLocaleOnly = saveAudio(creator, theme, "other-locale-source", now.minusDays(4))
val missing = saveAudio(creator, theme, "missing-source", now.minusDays(5))
saveContentTranslation(translated, "en", "translated-en")
saveContentTranslation(translated, "ja", "translated-ja")
saveContentTranslation(empty, "en", "")
saveContentTranslation(whitespace, "en", " ")
saveContentTranslation(otherLocaleOnly, "ja", "other-locale-ja")
flushAndClear()
val contentIds = listOf(missing.id!!, empty.id!!, whitespace.id!!, otherLocaleOnly.id!!, translated.id!!)
val englishCards = repository.findAudioCardsByIds(contentIds, null, false, now, Lang.EN.code)
val japaneseCards = repository.findAudioCardsByIds(contentIds, null, false, now, Lang.JA.code)
assertEquals(contentIds, englishCards.map { it.audioContentId })
assertEquals(
listOf("missing-source", "empty-source", "whitespace-source", "other-locale-source", "translated-en"),
englishCards.map { it.title }
)
assertEquals(contentIds, japaneseCards.map { it.audioContentId })
assertEquals(
listOf("missing-source", "empty-source", "whitespace-source", "other-locale-ja", "translated-ja"),
japaneseCards.map { it.title }
)
}
@Test
@DisplayName("최근 댓글 많은 오디오 native query는 locale별 제목만 바꾸고 fallback과 댓글 필드를 보존한다")
fun shouldTranslateCommentedAudiosByRequestedLocaleWithFallbackAndPreservedFields() {
val now = LocalDateTime.now().minusHours(1)
val creator = saveMember("translated-commented-creator", MemberRole.CREATOR)
val writer = saveMember("translated-commented-writer", MemberRole.USER)
val theme = saveTheme()
val translated = saveAudio(creator, theme, "commented-translated-source", now.minusMinutes(1))
val empty = saveAudio(creator, theme, "commented-empty-source", now.minusMinutes(2))
val whitespace = saveAudio(creator, theme, "commented-whitespace-source", now.minusMinutes(3))
val otherLocaleOnly = saveAudio(creator, theme, "commented-other-locale-source", now.minusMinutes(4))
val missing = saveAudio(creator, theme, "commented-missing-source", now.minusMinutes(5))
saveComment(translated, writer, "translated-comment", now)
saveComment(empty, writer, "empty-comment", now)
saveComment(whitespace, writer, "whitespace-comment", now)
saveComment(otherLocaleOnly, writer, "other-locale-comment", now)
saveComment(missing, writer, "missing-comment", now)
saveContentTranslation(translated, "en", "commented-translated-en")
saveContentTranslation(translated, "ja", "commented-translated-ja")
saveContentTranslation(empty, "en", "")
saveContentTranslation(whitespace, "en", " ")
saveContentTranslation(otherLocaleOnly, "ja", "commented-other-locale-ja")
flushAndClear()
val contentIds = listOf(missing.id!!, empty.id!!, whitespace.id!!, otherLocaleOnly.id!!, translated.id!!)
val englishAudios = repository.findCommentedAudiosByIds(contentIds, null, false, Lang.EN.code)
val japaneseAudios = repository.findCommentedAudiosByIds(contentIds, null, false, Lang.JA.code)
assertEquals(contentIds, englishAudios.map { it.audioContentId })
assertEquals(contentIds, japaneseAudios.map { it.audioContentId })
assertEquals(
listOf(
"commented-missing-source",
"commented-empty-source",
"commented-whitespace-source",
"commented-other-locale-source",
"commented-translated-en"
),
englishAudios.map { it.title }
)
assertEquals(
listOf(
"commented-missing-source",
"commented-empty-source",
"commented-whitespace-source",
"commented-other-locale-ja",
"commented-translated-ja"
),
japaneseAudios.map { it.title }
)
assertEquals(
listOf("missing-comment", "empty-comment", "whitespace-comment", "other-locale-comment", "translated-comment"),
englishAudios.map { it.latestComment }
)
assertEquals(englishAudios.map { it.latestComment }, japaneseAudios.map { it.latestComment })
assertEquals(
englishAudios.map { it.latestCommentWriterProfileImageUrl },
japaneseAudios.map { it.latestCommentWriterProfileImageUrl }
)
}
@Test @Test
@DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다") @DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다")
fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() { fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() {
@@ -356,7 +479,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val commented = repository.findCommentedAudiosByIds( val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(audio.id!!), contentIds = listOf(audio.id!!),
memberId = viewer.id, memberId = viewer.id,
canViewAdultContent = false canViewAdultContent = false,
locale = Lang.KO.code
) )
assertEquals(1, commented.size) assertEquals(1, commented.size)
@@ -381,7 +505,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val commented = repository.findCommentedAudiosByIds( val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(audio.id!!), contentIds = listOf(audio.id!!),
memberId = viewer.id, memberId = viewer.id,
canViewAdultContent = false canViewAdultContent = false,
locale = Lang.KO.code
) )
assertEquals(1, commented.size) assertEquals(1, commented.size)
@@ -507,6 +632,22 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
return audio return audio
} }
private fun saveContentTranslation(content: AudioContent, locale: String, title: String) {
val translation = ContentTranslation(
contentId = content.id!!,
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 saveView(audio: AudioContent, viewedAt: LocalDateTime) { private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
entityManager.persist( entityManager.persist(
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt) CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)