feat(creatorname): 레거시 조회와 검색에 언어별 이름을 적용한다

This commit is contained in:
2026-10-16 14:04:17 +09:00
parent 995b41e89d
commit eef2e2bf55
66 changed files with 3022 additions and 373 deletions
@@ -0,0 +1,328 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryRepository
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
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.explorer.CreatorRanking
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.rank.RankingRepository
import org.junit.jupiter.api.Assertions.assertAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.jdbc.core.JdbcTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:legacy-content-owner-name;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QueryDslConfig::class, RankingRepository::class, AudioContentCurationQueryRepository::class)
class AudioContentRepositoryLocalizedNameTest @Autowired constructor(
private val repository: AudioContentRepository,
private val seriesRepository: ContentSeriesRepository,
private val rankingRepository: RankingRepository,
private val curationRepository: AudioContentCurationQueryRepository,
private val entityManager: EntityManager,
private val jdbcTemplate: JdbcTemplate
) {
@Test
@DisplayName("JA 메인 콘텐츠 조회는 콘텐츠 ID가 아닌 실제 소유자 ID로 크리에이터 이름만 대체한다")
fun shouldLocalizeCurrentOwnerNameByCreatorIdWithoutChangingContentTitleOrUserName() {
val viewer = saveMember("owner-query-viewer", MemberRole.USER)
val creator = saveMember("owner-original", MemberRole.CREATOR)
val user = saveMember("user-original", MemberRole.USER)
val creatorContent = saveContent(creator, "Original audio title").takeIf { it.id != creator.id }
?: saveContent(creator, "Original audio title")
val userContent = saveContent(user, "User audio title")
saveAlias(creator, "Japanese owner")
saveAlias(user, "Must not replace USER")
entityManager.clear()
assertNotEquals(creator.id, creatorContent.id)
val items = findLatest(viewer.id!!, "ja").associateBy { it.contentId }
assertAll(
{ assertEquals("Japanese owner", items[creatorContent.id]!!.creatorNickname) },
{ assertEquals(creator.id, items[creatorContent.id]!!.creatorId) },
{ assertEquals("Original audio title", items[creatorContent.id]!!.title) },
{ assertEquals("user-original", items[userContent.id]!!.creatorNickname) },
{ assertEquals("User audio title", items[userContent.id]!!.title) },
{
assertEquals(
"owner-original",
findLatest(viewer.id!!, "ko").first { it.contentId == creatorContent.id }.creatorNickname
)
},
{
assertEquals(
"owner-original",
findLatest(viewer.id!!, "en").first { it.contentId == creatorContent.id }.creatorNickname
)
}
)
}
@Test
@DisplayName("메인 콘텐츠 이름 대체는 공개 필터를 유지하고 해제 후 변경된 현재 원본으로 돌아간다")
fun shouldPreserveVisibilityFiltersAndFallbackToRenamedOriginalAfterSoftClear() {
val viewer = saveMember("visibility-query-viewer", MemberRole.USER)
val creator = saveMember("before-rename", MemberRole.CREATOR)
val visible = saveContent(creator, "visible title")
saveContent(creator, "future title").releaseDate = LocalDateTime.now().plusDays(1)
saveContent(creator, "adult title").isAdult = true
saveContent(creator, "inactive title").isActive = false
saveContent(creator, "unfinished title").duration = null
val blockedCreator = saveMember("blocked-owner", MemberRole.CREATOR)
saveContent(blockedCreator, "blocked title")
entityManager.persist(
BlockMember(isActive = true).apply {
member = blockedCreator
blockedMember = viewer
}
)
saveAlias(creator, "Retained owner alias")
saveAlias(blockedCreator, "Blocked owner alias")
creator.nickname = "renamed-original"
entityManager.flush()
entityManager.clear()
val activeItems = findLatest(viewer.id!!, "ja")
assertEquals(listOf(visible.id), activeItems.map { it.contentId })
assertEquals("Retained owner alias", activeItems.single().creatorNickname)
jdbcTemplate.update(
"update creator_localized_name set is_active = false where member_id = ? and lang = 'JA'",
creator.id!!
)
entityManager.clear()
assertEquals("renamed-original", findLatest(viewer.id!!, "ja").single().creatorNickname)
}
@Test
@DisplayName("테마 공유 조회는 요청한 KO·JA 별칭과 기존 제목을 반환한다")
fun shouldLocalizeThemeNamesByRequestedLanguageWithoutChangingTitle() {
val viewer = saveMember("raw-theme-viewer", MemberRole.USER)
val creator = saveMember("raw-theme-original", MemberRole.CREATOR)
val content = saveContent(creator, "Unchanged theme title")
saveAlias(creator, "Japanese theme owner")
saveAlias(creator, "Korean theme owner", Lang.KO)
entityManager.clear()
val names = listOf(
Lang.KO to "Korean theme owner",
Lang.JA to "Japanese theme owner"
)
for ((lang, expected) in names) {
val item = repository.findByTheme(memberId = viewer.id!!, contentType = ContentType.ALL, lang = lang).single()
assertEquals(content.id, item.contentId)
assertEquals(expected, item.creatorNickname)
assertEquals("Unchanged theme title", item.title)
}
}
@Test
@DisplayName("큐레이션 조회는 활성 KO·JA 별칭이 있어도 원본 닉네임을 반환한다")
fun shouldKeepCurationNamesRawWithActiveAliases() {
val creator = saveMember("raw-curation-original", MemberRole.CREATOR)
val curation = AudioContentCuration(title = "curation", description = "description")
.also { entityManager.persist(it) }
val content = saveContent(creator, "Unchanged curation title").apply { this.curation = curation }
entityManager.persist(
AudioContentCurationItem().apply {
this.curation = curation
this.content = content
}
)
saveAlias(creator, "Japanese curation owner")
saveAlias(creator, "Korean curation owner", Lang.KO)
entityManager.clear()
val results = listOf(
repository.findContentByCurationId(curation.id!!, false, ContentType.ALL),
curationRepository.findByCurationId(curation.id!!, "https://cdn.test", contentType = ContentType.ALL)
)
for (items in results) {
assertEquals(content.id, items.single().contentId)
assertEquals("raw-curation-original", items.single().creatorNickname)
assertEquals("Unchanged curation title", items.single().title)
}
}
@Test
@DisplayName("랭킹 공유 조회는 요청한 KO·JA 별칭과 제목 번역·원본 fallback을 반환한다")
fun shouldLocalizeRankingNamesAndTitlesByRequestedLocale() {
val viewer = saveMember("raw-rank-viewer", MemberRole.USER)
val creator = saveMember("raw-rank-original", MemberRole.CREATOR).apply { profileImage = "profile.png" }
val content = saveContent(creator, "Original ranking title")
entityManager.persist(CreatorRanking(ranking = 1).apply { member = creator })
entityManager.persist(
Order(type = OrderType.KEEP).apply {
member = viewer
this.creator = creator
audioContent = content
}
)
entityManager.persist(
ContentTranslation(content.id!!, "ja", ContentTranslationPayload("Translated ranking title", "detail", ""))
)
saveAlias(creator, "Japanese ranking owner")
saveAlias(creator, "Korean ranking owner", Lang.KO)
// H2 JSON 컬럼에 문자열이 아닌 JSON 객체를 적재한다.
jdbcTemplate.update(
"update content_translation set rendered_payload = ? format json where content_id = ? and locale = 'ja'",
"""{"title":"Translated ranking title","detail":"detail","tags":""}""",
content.id!!
)
entityManager.clear()
val names = listOf(
Triple(Lang.KO, "Korean ranking owner", "Original ranking title"),
Triple(Lang.JA, "Japanese ranking owner", "Translated ranking title")
)
for ((lang, expected, expectedTitle) in names) {
assertEquals(expected, rankingRepository.getCreatorRankings(viewer.id, lang).single().nickname)
val item = rankingRepository.getAudioContentRanking(
memberId = viewer.id,
isAdult = false,
contentType = ContentType.ALL,
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1),
offset = 0,
limit = 10,
sortType = "판매량",
locale = lang.code
).single()
assertEquals(expected, item.creatorNickname)
assertEquals(expectedTitle, item.title)
}
assertEquals(
"Korean ranking owner",
rankingRepository.getCreatorRankings(viewer.id).single().nickname
)
}
@Test
@DisplayName("오리지널 시리즈 공유 조회는 요청한 언어의 제목 번역·원본 fallback을 반환한다")
fun shouldLocalizeOriginalSeriesTitlesByRequestedLocale() {
val creator = saveMember("raw-series-original", MemberRole.CREATOR)
val content = saveContent(creator, "Series content")
val genre = SeriesGenre(genre = "genre").also { entityManager.persist(it) }
val series = Series(title = "Original series title", introduction = "introduction", isOriginal = true).apply {
member = creator
this.genre = genre
coverImage = "series.png"
entityManager.persist(this)
}
entityManager.persist(
SeriesContent().apply {
this.series = series
this.content = content
}
)
entityManager.persist(
SeriesTranslation(series.id!!, "ja", SeriesTranslationPayload("Translated series title", "intro", emptyList()))
)
entityManager.flush()
// H2 JSON 컬럼에 문자열이 아닌 JSON 객체를 적재한다.
jdbcTemplate.update(
"update series_translation set rendered_payload = ? format json where series_id = ? and locale = 'ja'",
"""{"title":"Translated series title","introduction":"intro","keywords":[]}""",
series.id!!
)
entityManager.clear()
val titles = listOf(
"ja" to "Translated series title",
"ko" to "Original series title"
)
for ((locale, expectedTitle) in titles) {
val localized = seriesRepository.getOriginalAudioDramaList(
imageHost = "https://cdn.test",
isAdult = false,
contentType = ContentType.ALL,
locale = locale
).single()
assertEquals(expectedTitle, localized.title)
}
}
private fun findLatest(viewerId: Long, locale: String): List<AudioContentMainItem> {
return repository.getLatestContentByTheme(
memberId = viewerId,
theme = emptyList(),
contentType = ContentType.ALL,
offset = 0,
limit = 20,
sortType = SortType.NEWEST,
isFree = false,
isAdult = false,
locale = locale
)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "profile.png",
role = role
).also {
entityManager.persist(it)
}
}
private fun saveContent(creator: Member, title: String): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
entityManager.persist(theme)
return AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = LocalDateTime.now().minusDays(1)
).apply {
member = creator
this.theme = theme
isActive = true
coverImage = "audio.png"
duration = "00:10:00"
entityManager.persist(this)
}
}
private fun saveAlias(member: Member, displayName: String, lang: Lang = Lang.JA) {
entityManager.flush()
jdbcTemplate.update(
"insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " +
"values (?, ?, ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
member.id!!,
lang.name,
displayName
)
}
}
@@ -24,6 +24,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.support.originalLocalizedNameService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.junit.jupiter.api.Assertions.assertEquals
@@ -114,6 +115,7 @@ class AudioContentServiceTest {
applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(),
langContext = LangContext(),
localizedNameService = originalLocalizedNameService(),
contentThemeTranslationRepository = contentThemeTranslationRepository,
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
memberContentPreferenceService = memberContentPreferenceService,
@@ -0,0 +1,91 @@
package kr.co.vividnext.sodalive.content.comment
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class)
class AudioContentCommentNameMetadataTest @Autowired constructor(
private val entityManager: EntityManager,
private val repository: AudioContentCommentRepository,
private val blockRepository: BlockMemberRepository,
private val contentRepository: AudioContentRepository,
private val orderRepository: OrderRepository,
private val localizedNameService: CreatorLocalizedNameService
) {
@ParameterizedTest
@CsvSource("CREATOR, USER", "USER, CREATOR")
@DisplayName("답글 알림 제목은 부모 작성자 ID이고 snapshot 발신자 ID와 콘텐츠 제목 인자는 그대로다")
fun shouldKeepParentTitleOwnerSeparateFromActualCommenterAndResourceTitle(
parentRole: MemberRole,
commenterRole: MemberRole
) {
val parentWriter = Member(nickname = "parent-original", password = "password", role = parentRole)
val commenter = Member(nickname = "commenter-original", password = "password", role = commenterRole)
val contentOwner = Member(nickname = "content-owner-original", password = "password", role = MemberRole.CREATOR)
entityManager.persist(parentWriter)
entityManager.persist(commenter)
entityManager.persist(contentOwner)
val theme = AudioContentTheme(theme = "metadata-theme", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(title = "Unchanged content title", detail = "detail", languageCode = "ko").apply {
member = contentOwner
this.theme = theme
isActive = true
entityManager.persist(this)
}
val parent = AudioContentComment(comment = "parent comment", languageCode = "ko").apply {
member = parentWriter
audioContent = content
entityManager.persist(this)
}
entityManager.flush()
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val service = AudioContentCommentService(
repository,
blockRepository,
contentRepository,
publisher,
orderRepository,
SodaMessageSource(),
LangContext(),
localizedNameService,
"https://cdn.test"
)
service.registerComment(commenter, "reply", content.id!!, parent.id!!, false, "ko")
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(publisher).publishEvent(captor.capture())
val event = captor.value
assertNotEquals(parentWriter.id, commenter.id)
assertEquals(parentWriter.id, event.titleMemberId)
assertEquals(commenter.id, event.senderMemberId)
assertEquals("parent-original", event.title)
assertEquals(listOf("Unchanged content title"), event.args)
assertEquals(emptyMap<Int, Long>(), event.nameArgumentMemberIds)
}
}