feat(chat-banner): 다국어 캐릭터 배너 등록과 노출을 지원한다

배너를 언어별로 저장하고 요청 언어 우선 조회 후 한국어로 fallback 하도록 맞춘다.
This commit is contained in:
2026-04-02 15:32:42 +09:00
parent 06acfae1c9
commit ee14389786
12 changed files with 543 additions and 5 deletions
@@ -0,0 +1,118 @@
package kr.co.vividnext.sodalive.chat.character.controller
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
class ChatCharacterControllerTest {
private val service = Mockito.mock(ChatCharacterService::class.java)
private val bannerService = Mockito.mock(ChatCharacterBannerService::class.java)
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val controller = ChatCharacterController(
service = service,
bannerService = bannerService,
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
@Test
fun shouldUseJapaneseBannerWhenLangContextIsJapanese() {
val pageable = PageRequest.of(0, 10)
val banner = createBanner(id = 1L, imagePath = "banner/jp.png", lang = Lang.JA)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA))
.thenReturn(PageImpl(listOf(banner), pageable, 1))
Mockito.`when`(service.getPopularCharacters(locale = "ja")).thenReturn(emptyList())
Mockito.`when`(service.getRecentCharactersPage(page = 0, size = 50))
.thenReturn(RecentCharactersResponse(totalCount = 0, content = emptyList()))
Mockito.`when`(service.getRecommendCharacters(emptyList(), 30)).thenReturn(emptyList())
Mockito.`when`(curationQueryService.getActiveCurationsWithCharacters()).thenReturn(emptyList())
val response = controller.getCharacterMain(member = null)
assertTrue(response.success)
assertEquals(1, response.data?.banners?.size)
assertEquals(1L, response.data?.banners?.first()?.characterId)
assertEquals("https://cdn.test/banner/jp.png", response.data?.banners?.first()?.imageUrl)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA)
}
@Test
fun shouldUseEnglishRequestWithKoreanFallbackBanner() {
val controller = ChatCharacterController(
service = service,
bannerService = bannerService,
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = LangContext().apply { setLang(Lang.EN) },
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
val pageable = PageRequest.of(0, 10)
val banner = createBanner(id = 2L, imagePath = "banner/ko.png", lang = Lang.KO)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.EN))
.thenReturn(PageImpl(listOf(banner), pageable, 1))
Mockito.`when`(service.getPopularCharacters(locale = "en")).thenReturn(emptyList())
Mockito.`when`(service.getRecentCharactersPage(page = 0, size = 50))
.thenReturn(RecentCharactersResponse(totalCount = 0, content = emptyList()))
Mockito.`when`(service.getRecommendCharacters(emptyList(), 30)).thenReturn(emptyList())
Mockito.`when`(curationQueryService.getActiveCurationsWithCharacters()).thenReturn(emptyList())
val response = controller.getCharacterMain(member = null)
assertTrue(response.success)
assertEquals("https://cdn.test/banner/ko.png", response.data?.banners?.first()?.imageUrl)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.EN)
}
private fun createBanner(id: Long, imagePath: String, lang: Lang): ChatCharacterBanner {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}
@@ -0,0 +1,177 @@
package kr.co.vividnext.sodalive.chat.character.service
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import java.util.Optional
class ChatCharacterBannerServiceTest {
private lateinit var bannerRepository: ChatCharacterBannerRepository
private lateinit var characterRepository: ChatCharacterRepository
private lateinit var service: ChatCharacterBannerService
@BeforeEach
fun setUp() {
bannerRepository = Mockito.mock(ChatCharacterBannerRepository::class.java)
characterRepository = Mockito.mock(ChatCharacterRepository::class.java)
service = ChatCharacterBannerService(
bannerRepository = bannerRepository,
characterRepository = characterRepository
)
}
@Test
@DisplayName("일본어 배너 등록 요청은 JA 언어값으로 저장한다")
fun shouldRegisterJapaneseBanner() {
val character = createCharacter(id = 1L)
Mockito.`when`(characterRepository.findById(1L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(3)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 1L,
imagePath = "banner/jp.png",
lang = Lang.JA
)
assertEquals(Lang.JA, banner.lang)
assertEquals(4, banner.sortOrder)
assertEquals("banner/jp.png", banner.imagePath)
}
@Test
@DisplayName("기본 배너 등록 요청은 언어값이 없으면 KO로 저장한다")
fun shouldRegisterDefaultBannerAsKoreanWhenLangIsNull() {
val character = createCharacter(id = 2L)
Mockito.`when`(characterRepository.findById(2L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(null)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 2L,
imagePath = "banner/default.png",
lang = null
)
assertEquals(Lang.KO, banner.lang)
assertEquals(1, banner.sortOrder)
}
@Test
@DisplayName("영어 배너 등록 요청은 EN 언어값으로 저장한다")
fun shouldRegisterEnglishBanner() {
val character = createCharacter(id = 3L)
Mockito.`when`(characterRepository.findById(3L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(5)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 3L,
imagePath = "banner/en.png",
lang = Lang.EN
)
assertEquals(Lang.EN, banner.lang)
assertEquals(6, banner.sortOrder)
}
@Test
@DisplayName("일본어 사용자는 일본어 배너만 조회한다")
fun shouldReturnJapaneseBannersForJapaneseUser() {
val pageable = PageRequest.of(0, 10)
val japaneseBanner = ChatCharacterBanner(
imagePath = "banner/jp.png",
chatCharacter = createCharacter(id = 4L),
sortOrder = 1,
lang = Lang.JA
)
val expectedPage = PageImpl(listOf(japaneseBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.JA)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueAndLangOrderBySortOrderAsc(
Lang.KO,
pageable
)
}
@Test
@DisplayName("한국어 사용자는 한국어 배너를 조회한다")
fun shouldReturnKoreanBannersForKoreanUser() {
val pageable = PageRequest.of(0, 10)
val defaultBanner = ChatCharacterBanner(
imagePath = "banner/ko.png",
chatCharacter = createCharacter(id = 5L),
sortOrder = 1,
lang = Lang.KO
)
val expectedPage = PageImpl(listOf(defaultBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.KO)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueAndLangOrderBySortOrderAsc(
Lang.JA,
pageable
)
}
@Test
@DisplayName("영어 배너가 없으면 한국어 배너로 fallback 한다")
fun shouldFallbackToKoreanWhenEnglishBannerDoesNotExist() {
val pageable = PageRequest.of(0, 10)
val englishPage = PageImpl<ChatCharacterBanner>(emptyList(), pageable, 0)
val koreanBanner = ChatCharacterBanner(
imagePath = "banner/ko.png",
chatCharacter = createCharacter(id = 6L),
sortOrder = 1,
lang = Lang.KO
)
val koreanPage = PageImpl(listOf(koreanBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.EN, pageable))
.thenReturn(englishPage)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable))
.thenReturn(koreanPage)
val actual = service.getDisplayBanners(pageable, Lang.EN)
assertEquals(koreanPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.EN, pageable)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
private fun createCharacter(id: Long): ChatCharacter {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return character
}
}