diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 324f5bbf..70923408 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -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.QAudioContentBanner.audioContentBanner 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.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.event.EventItem @@ -38,6 +39,7 @@ import javax.persistence.EntityManager class DefaultAudioRecommendationQueryRepository( private val queryFactory: JPAQueryFactory, private val entityManager: EntityManager, + private val contentTranslationRepository: ContentTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) : AudioRecommendationQueryRepository { @@ -115,32 +117,35 @@ class DefaultAudioRecommendationQueryRepository( limit: Int, memberId: Long?, canViewAdultContent: Boolean, - now: LocalDateTime + now: LocalDateTime, + locale: String ): List { val rows = audioRows(memberId, canViewAdultContent, now) { orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong()) } - return rows.toAudioCards(now, canViewAdultContent) + return rows.toAudioCards(now, canViewAdultContent, locale) } override fun findFreeAudios( limit: Int, memberId: Long?, canViewAdultContent: Boolean, - now: LocalDateTime + now: LocalDateTime, + locale: String ): List { val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) { orderBy(randomTieBreaker.asc()).limit(limit.toLong()) } - return rows.toAudioCards(now, canViewAdultContent) + return rows.toAudioCards(now, canViewAdultContent, locale) } override fun findPointAudios( limit: Int, memberId: Long?, canViewAdultContent: Boolean, - now: LocalDateTime + now: LocalDateTime, + locale: String ): List { val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") val rows = audioRows( @@ -151,25 +156,27 @@ class DefaultAudioRecommendationQueryRepository( ) { orderBy(randomTieBreaker.asc()).limit(limit.toLong()) } - return rows.toAudioCards(now, canViewAdultContent) + return rows.toAudioCards(now, canViewAdultContent, locale) } override fun findAudioCardsByIds( contentIds: List, memberId: Long?, canViewAdultContent: Boolean, - now: LocalDateTime + now: LocalDateTime, + locale: String ): List { if (contentIds.isEmpty()) return emptyList() val orderById = contentIds.withIndex().associate { it.value to it.index } 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( contentIds: List, memberId: Long?, - canViewAdultContent: Boolean + canViewAdultContent: Boolean, + locale: String ): List { if (contentIds.isEmpty()) return emptyList() val contentOrder = contentIds.withIndex().associate { it.value to it.index } @@ -235,7 +242,7 @@ class DefaultAudioRecommendationQueryRepository( ) ) """.trimIndent() - return entityManager.createNativeQuery(sql) + val commentedAudios = entityManager.createNativeQuery(sql) .setParameter("contentIds", contentIds) .setParameter("memberId", memberId) .setParameter("canViewAdultContent", canViewAdultContent) @@ -251,6 +258,11 @@ class DefaultAudioRecommendationQueryRepository( ?: "$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 } } @@ -455,18 +467,23 @@ class DefaultAudioRecommendationQueryRepository( .fetch() } - private fun List.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List { + private fun List.toAudioCards( + now: LocalDateTime, + canViewAdultContent: Boolean, + locale: String + ): List { if (isEmpty()) return emptyList() val contentIds = map { it.get(audioContent.id)!! } val creatorIds = map { it.get(audioContent.member.id)!! }.distinct() val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent) val isOriginalSeriesByContentId = originalSeriesFlags(contentIds) + val translatedTitleByContentId = translatedTitles(contentIds, locale) return map { row -> val contentId = row.get(audioContent.id)!! val creatorId = row.get(audioContent.member.id)!! AudioCard( audioContentId = contentId, - title = row.get(audioContent.title)!!, + title = translatedTitleByContentId[contentId] ?: row.get(audioContent.title)!!, duration = row.get(audioContent.duration), imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost), price = row.get(audioContent.price)!!, @@ -479,6 +496,17 @@ class DefaultAudioRecommendationQueryRepository( } } + private fun translatedTitles(contentIds: List, locale: String): Map { + 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( creatorIds: List, now: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt index 6d7092b2..84c0629d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt @@ -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.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.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -93,6 +95,44 @@ class ContentOverviewEndToEndTest @Autowired constructor( .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 @DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다") 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( val viewer: Member, val audioContentId: Long diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index a2426a31..7154a39f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -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.comment.AudioContentComment 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.SeriesContent 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.recommendedAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId)) - .andExpect(jsonPath("$.data.pointAudios.length()").value(1)) - .andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId)) - .andExpect(jsonPath("$.data.pointAudios[0].price").value(100)) + .andExpect( + jsonPath( + "$.data.pointAudios[?(@.audioContentId == ${fixture.paidPointAudioContentId} && @.price == 100)]" + ).isNotEmpty + ) .andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment")) .andExpect( 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 { return transactionTemplate.execute { 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( val seriesId: Long, val audioContentId: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index acdf8ea2..09285d47 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -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.tab.AudioContentMainTab 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.SeriesContent import kr.co.vividnext.sodalive.i18n.Lang @@ -38,9 +41,15 @@ import javax.persistence.EntityManager @Import(QueryDslConfig::class) class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( 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 @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다") @@ -210,9 +219,27 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( } flushAndClear() - val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now) - val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now) - val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now) + val latestAudios = repository.findLatestAudios( + 12, + 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(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId }) @@ -297,7 +324,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( val commented = repository.findCommentedAudiosByIds( contentIds = listOf(first.id!!, second.id!!), memberId = viewer.id, - canViewAdultContent = false + canViewAdultContent = false, + locale = Lang.KO.code ) 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) } + @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 @DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다") fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() { @@ -356,7 +479,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( val commented = repository.findCommentedAudiosByIds( contentIds = listOf(audio.id!!), memberId = viewer.id, - canViewAdultContent = false + canViewAdultContent = false, + locale = Lang.KO.code ) assertEquals(1, commented.size) @@ -381,7 +505,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( val commented = repository.findCommentedAudiosByIds( contentIds = listOf(audio.id!!), memberId = viewer.id, - canViewAdultContent = false + canViewAdultContent = false, + locale = Lang.KO.code ) assertEquals(1, commented.size) @@ -507,6 +632,22 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( 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) { entityManager.persist( CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)