test #449
+40
-12
@@ -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,
|
||||||
|
|||||||
+58
@@ -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
|
||||||
|
|||||||
+72
-3
@@ -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,
|
||||||
|
|||||||
+149
-8
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user