test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
3 changed files with 159 additions and 18 deletions
Showing only changes of commit 21f72f2827 - Show all commits
@@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.chat.room.QChatMessage.chatMessage
import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant
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.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.event.QEvent.event import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
@@ -50,7 +51,8 @@ import javax.persistence.EntityManager
@Repository @Repository
class DefaultHomeRecommendationQueryRepository( class DefaultHomeRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository
) : HomeRecommendationQueryRepository { ) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Long, offset: Long,
@@ -367,7 +369,8 @@ class DefaultHomeRecommendationQueryRepository(
offset: Long, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean,
locale: String
): List<HomeFirstAudioContentRecord> { ): List<HomeFirstAudioContentRecord> {
val sql = """ val sql = """
with creator_debut as ( with creator_debut as (
@@ -475,7 +478,7 @@ class DefaultHomeRecommendationQueryRepository(
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val rows = query.resultList as List<Array<Any?>> val rows = query.resultList as List<Array<Any?>>
return rows.map { row -> val firstAudioContents = rows.map { row ->
HomeFirstAudioContentRecord( HomeFirstAudioContentRecord(
contentId = (row[0] as Number).toLong(), contentId = (row[0] as Number).toLong(),
creatorId = (row[1] as Number).toLong(), creatorId = (row[1] as Number).toLong(),
@@ -489,6 +492,19 @@ class DefaultHomeRecommendationQueryRepository(
isOriginalSeries = row[9].toNativeBoolean() isOriginalSeries = row[9].toNativeBoolean()
) )
} }
if (firstAudioContents.isEmpty()) return emptyList()
val translatedTitleByContentId = contentTranslationRepository
.findByContentIdInAndLocale(firstAudioContents.map { it.contentId }, locale)
.mapNotNull { translation ->
translation.renderedPayload.title
.takeIf { it.isNotBlank() }
?.let { translation.contentId to it }
}
.toMap()
return firstAudioContents.map { audio ->
translatedTitleByContentId[audio.contentId]?.let { audio.copy(title = it) } ?: audio
}
} }
override fun findAiCharacterSnapshots( override fun findAiCharacterSnapshots(
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.home.application package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.i18n.Lang 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.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
@@ -26,7 +27,11 @@ import java.time.LocalDateTime
class HomeRecommendationFacadeTest { class HomeRecommendationFacadeTest {
private val queryPort = FakeHomeRecommendationQueryPort() private val queryPort = FakeHomeRecommendationQueryPort()
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort()) private val queryService = HomeRecommendationQueryService(
queryPort,
EmptyRecommendationSnapshotPort(),
langContext = LangContext()
)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test") private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
@@ -135,7 +140,8 @@ class HomeRecommendationFacadeTest {
offset: Long, offset: Long,
limit: Int, limit: Int,
memberId: Long?, memberId: Long?,
includeAdultContents: Boolean includeAdultContents: Boolean,
locale: String
): List<HomeFirstAudioContentRecord> = firstAudioContents ): List<HomeFirstAudioContentRecord> = firstAudioContents
override fun findAiCharacterSnapshots( override fun findAiCharacterSnapshots(
@@ -21,6 +21,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.event.Event import kr.co.vividnext.sodalive.event.Event
@@ -63,9 +66,10 @@ import javax.persistence.Query
@Import(QueryDslConfig::class) @Import(QueryDslConfig::class)
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager, private val entityManager: EntityManager,
queryFactory: JPAQueryFactory queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository
) { ) {
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager) private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository)
private val scorePolicy = RecommendationScorePolicy() private val scorePolicy = RecommendationScorePolicy()
@Test @Test
@@ -1565,12 +1569,76 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) } ).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) }
flushAndClear() flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10) val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId }) assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
assertEquals(12, contents.single().price) assertEquals(12, contents.single().price)
} }
@Test
@DisplayName("첫 오디오 native query는 빈 문자열, 공백, 누락, 다른 언어 번역을 원문으로 대체한다")
fun shouldTranslateFirstAudioContentsByRequestedLocaleWithFallback() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val translated = saveAudioContent(
saveMember("translated-first-audio", MemberRole.CREATOR),
now.minusDays(1),
isActive = true
)
val empty = saveAudioContent(
saveMember("empty-first-audio", MemberRole.CREATOR),
now.minusDays(2),
isActive = true
)
val whitespace = saveAudioContent(
saveMember("whitespace-first-audio", MemberRole.CREATOR),
now.minusDays(3),
isActive = true
)
val otherLocaleOnly = saveAudioContent(
saveMember("other-locale-first-audio", MemberRole.CREATOR),
now.minusDays(4),
isActive = true
)
val missing = saveAudioContent(
saveMember("missing-first-audio", MemberRole.CREATOR),
now.minusDays(5),
isActive = true
)
saveContentTranslation(translated, "en", "first-audio-en")
saveContentTranslation(translated, "ja", "first-audio-ja")
saveContentTranslation(empty, "en", "")
saveContentTranslation(whitespace, "en", " ")
saveContentTranslation(otherLocaleOnly, "en", "other-locale-first-audio-en")
flushAndClear()
val englishRecords = repository.findFirstAudioContents(now, limit = 10, locale = Lang.EN.code)
val japaneseRecords = repository.findFirstAudioContents(now, limit = 10, locale = Lang.JA.code)
assertEquals(englishRecords.map { it.contentId }, japaneseRecords.map { it.contentId })
assertEquals(
"first-audio-en",
englishRecords.single { it.contentId == translated.id }.title
)
assertEquals(empty.title, englishRecords.single { it.contentId == empty.id }.title)
assertEquals(whitespace.title, englishRecords.single { it.contentId == whitespace.id }.title)
assertEquals(
"other-locale-first-audio-en",
englishRecords.single { it.contentId == otherLocaleOnly.id }.title
)
assertEquals(missing.title, englishRecords.single { it.contentId == missing.id }.title)
assertEquals(
"first-audio-ja",
japaneseRecords.single { it.contentId == translated.id }.title
)
assertEquals(empty.title, japaneseRecords.single { it.contentId == empty.id }.title)
assertEquals(whitespace.title, japaneseRecords.single { it.contentId == whitespace.id }.title)
assertEquals(
otherLocaleOnly.title,
japaneseRecords.single { it.contentId == otherLocaleOnly.id }.title
)
assertEquals(missing.title, japaneseRecords.single { it.contentId == missing.id }.title)
}
@Test @Test
@DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다") @DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다")
fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() { fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() {
@@ -1585,7 +1653,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("AudioContent", old.id!!, now.minusDays(20)) updateCreatedAt("AudioContent", old.id!!, now.minusDays(20))
flushAndClear() flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10) val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId }) assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId })
} }
@@ -1602,8 +1670,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false) val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false)
flushAndClear() flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 2, includeAdultContents = false) val page0 = repository.findFirstAudioContents(
val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 2, includeAdultContents = false) now,
offset = 0L,
limit = 2,
includeAdultContents = false,
locale = Lang.KO.code
)
val page1 = repository.findFirstAudioContents(
now,
offset = 1L,
limit = 2,
includeAdultContents = false,
locale = Lang.KO.code
)
assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId }) assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId })
assertEquals(listOf(oldest.id), page1.map { it.contentId }) assertEquals(listOf(oldest.id), page1.map { it.contentId })
@@ -1622,7 +1702,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("AudioContent", content.id!!, now.minusDays(1)) updateCreatedAt("AudioContent", content.id!!, now.minusDays(1))
flushAndClear() flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10) val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
assertEquals(false, contents.single().isAdult) assertEquals(false, contents.single().isAdult)
assertEquals(true, contents.single().isOriginalSeries) assertEquals(true, contents.single().isOriginalSeries)
@@ -1633,12 +1713,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldMapNumericNativeBooleanFromFirstAudioContentRows() { fun shouldMapNumericNativeBooleanFromFirstAudioContentRows() {
val mockEntityManager = Mockito.mock(EntityManager::class.java) val mockEntityManager = Mockito.mock(EntityManager::class.java)
val mockQuery = Mockito.mock(Query::class.java) val mockQuery = Mockito.mock(Query::class.java)
val mockContentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
val repository = DefaultHomeRecommendationQueryRepository( val repository = DefaultHomeRecommendationQueryRepository(
JPAQueryFactory(mockEntityManager), JPAQueryFactory(mockEntityManager),
mockEntityManager mockEntityManager,
mockContentTranslationRepository
) )
Mockito.`when`(mockEntityManager.createNativeQuery(anyString())).thenReturn(mockQuery) Mockito.`when`(mockEntityManager.createNativeQuery(anyString())).thenReturn(mockQuery)
Mockito.`when`(mockQuery.setParameter(anyString(), any())).thenReturn(mockQuery) Mockito.`when`(mockQuery.setParameter(anyString(), any())).thenReturn(mockQuery)
Mockito.`when`(mockContentTranslationRepository.findByContentIdInAndLocale(listOf(1L), Lang.KO.code))
.thenReturn(emptyList())
Mockito.`when`(mockQuery.resultList).thenReturn( Mockito.`when`(mockQuery.resultList).thenReturn(
listOf( listOf(
arrayOf( arrayOf(
@@ -1661,7 +1745,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
offset = 0L, offset = 0L,
limit = 1, limit = 1,
memberId = null, memberId = null,
includeAdultContents = false includeAdultContents = false,
locale = Lang.KO.code
) )
assertEquals(true, contents.single().isOriginalSeries) assertEquals(true, contents.single().isOriginalSeries)
@@ -1682,7 +1767,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
saveBlock(creatorBlockedViewer, viewer) saveBlock(creatorBlockedViewer, viewer)
flushAndClear() flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id) val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id, locale = Lang.KO.code)
assertEquals(listOf(visibleContent.id), contents.map { it.contentId }) assertEquals(listOf(visibleContent.id), contents.map { it.contentId })
} }
@@ -1699,9 +1784,27 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true) val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear() flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 1, includeAdultContents = false) val page0 = repository.findFirstAudioContents(
val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 1, includeAdultContents = false) now,
val page2 = repository.findFirstAudioContents(now, offset = 2L, limit = 1, includeAdultContents = false) offset = 0L,
limit = 1,
includeAdultContents = false,
locale = Lang.KO.code
)
val page1 = repository.findFirstAudioContents(
now,
offset = 1L,
limit = 1,
includeAdultContents = false,
locale = Lang.KO.code
)
val page2 = repository.findFirstAudioContents(
now,
offset = 2L,
limit = 1,
includeAdultContents = false,
locale = Lang.KO.code
)
val pagedContentIds = (page0 + page1 + page2).map { it.contentId } val pagedContentIds = (page0 + page1 + page2).map { it.contentId }
assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet()) assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet())
@@ -2621,6 +2724,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return content return content
} }
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 saveTheme(name: String, isActive: Boolean = true): AudioContentTheme { private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
val theme = AudioContentTheme( val theme = AudioContentTheme(
theme = name, theme = name,