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

View File

@@ -0,0 +1,41 @@
SET @schema_name := DATABASE();
SET @lang_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'chat_character_banner'
AND column_name = 'lang'
);
SET @add_lang_column_sql := IF(
@lang_column_exists = 0,
'ALTER TABLE chat_character_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''배너 노출 언어'' AFTER sort_order',
'SELECT ''chat_character_banner.lang already exists'' AS message'
);
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
EXECUTE add_lang_column_stmt;
DEALLOCATE PREPARE add_lang_column_stmt;
UPDATE chat_character_banner
SET lang = 'KO'
WHERE lang IS NULL;
SET @lang_column_nullable := (
SELECT IS_NULLABLE
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'chat_character_banner'
AND column_name = 'lang'
);
SET @alter_lang_column_sql := IF(
@lang_column_nullable = 'YES',
'ALTER TABLE chat_character_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''배너 노출 언어 (KO 기본, EN/JA 추가 가능)''',
'SELECT ''chat_character_banner.lang already normalized'' AS message'
);
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
EXECUTE alter_lang_column_stmt;
DEALLOCATE PREPARE alter_lang_column_stmt;

View File

@@ -0,0 +1,25 @@
- [x] ChatCharacterBanner 엔티티에 한국어 기본 배너와 일본어/영어 배너를 구분할 언어 필드를 유지한다.
- [x] 관리자 배너 등록 API가 기본언어 한국어를 기본값으로 사용하고, 일본어/영어 배너도 등록할 수 있도록 요청값과 서비스 로직을 수정한다.
- [x] 캐릭터 메인 배너 조회가 요청 언어 배너를 우선 조회하고, 없으면 한국어 배너를 fallback 하도록 수정한다.
- [x] 관련 테스트 또는 검증을 수행하고 결과를 기록한다.
- [x] 관리자 배너 등록 요청의 `lang`이 ISO 639 언어코드(`ko`, `en`, `ja`)로 들어와도 `Lang` enum으로 역직렬화되도록 수정한다.
## 검증 기록
### 1차 구현
- 무엇을: 채팅 캐릭터 배너를 기본 배너와 일본어 배너 행으로 분리해 저장하도록 `lang` 필드를 추가하고, 관리자 등록 API와 메인 배너 조회 로직을 일본어 기준으로 분기했다. 운영 반영용 MySQL DDL 문서 `docs/20260402_chat_character_banner_lang_ddl.sql`도 함께 추가했다.
- 왜: 현재 배너 구조는 이미지 1개 기준 행 모델이라 동일 목적지에 여러 언어 이미지를 한 레코드에 묶는 것보다, 언어별 행 분리가 기존 정렬/활성화/수정 흐름을 가장 적게 건드리는 방식이기 때문이다.
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 서비스 분기와 언어 검증을 확인했고, `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest`로 등록 API와 메인 조회 API 흐름을 실행 검증했다. 이어서 `./gradlew ktlintCheck`, `./gradlew build`를 실행했다.
- 결과: 지정한 테스트는 모두 성공했고, `ktlintCheck``build`도 모두 성공했다. Kotlin LSP 서버가 없어 `lsp_diagnostics`는 수행할 수 없었다.
### 2차 수정
- 무엇을: 배너 기본언어를 명시적 `KO`로 변경하고, 등록 가능 언어를 `KO`, `EN`, `JA`로 확장했다. 또한 메인 배너 조회는 요청 언어 배너가 없을 때 `KO` 배너로 fallback 하도록 수정했고, MySQL DDL도 `NULL -> KO` 데이터 정규화와 `NOT NULL DEFAULT 'KO'`로 보강했다.
- 왜: 기본 배너를 `null`로 해석하는 방식보다 `KO`를 명시 저장하는 방식이 등록 규칙과 조회 fallback 규칙을 더 일관되게 표현하고, 영어 배너 추가 요구사항도 자연스럽게 수용할 수 있기 때문이다.
- 어떻게: 서비스 로직과 테스트를 `KO/EN/JA` 기준으로 재작성하고, 관리자 등록 API 기본값과 메인 조회 경로를 대상으로 단위 테스트를 추가·수정했다. 이후 `ktlintCheck`, 대상 테스트, 전체 빌드를 다시 실행했다.
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 모두 실행했고 전부 성공했다. 관리자 등록 테스트에서는 `lang`이 없을 때 `registerBanner(2L, "", null)` 호출이 발생하고 성공 응답이 반환되는 것을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 이번에도 수행하지 못했다.
### 3차 수정
- 무엇을: `Lang` enum에 Jackson `@JsonCreator` 기반 역직렬화 진입점을 추가해 관리자 배너 등록 요청의 `lang``ko`, `en`, `ja` 같은 ISO 639 코드로 들어와도 `Lang.KO`, `Lang.EN`, `Lang.JA`로 파싱되도록 수정했다. 기존 enum 이름(`KO`, `EN`, `JA`) 입력도 계속 허용했다.
- 왜: 관리자 요청에서 `Lang` enum을 직접 받고 있으므로, 외부에서 ISO 639 코드 값을 보내더라도 별도 DTO 변환 없이 안전하게 처리되게 해야 하기 때문이다.
- 어떻게: `Lang.fromCode(...)`를 Jackson 역직렬화 팩토리로 연결하고, 관리자 배너 컨트롤러 테스트 요청값을 `"ja"`로 바꿨다. 또한 `ObjectMapper().readValue(...)``"en"` 입력이 실제 `Lang.EN`으로 역직렬화되는 테스트를 추가했다.
- 결과: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`, `./gradlew ktlintCheck`, `./gradlew build`를 실행했고 모두 성공했다. 관리자 배너 등록 테스트는 실제 요청 문자열 `{"characterId":1,"lang":"ja"}` 를 사용해 성공 응답과 `registerBanner(..., Lang.JA)` 호출을 확인했다. Kotlin LSP 서버는 없어 `lsp_diagnostics`는 수행하지 못했다.

View File

@@ -127,7 +127,8 @@ class AdminChatBannerController(
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함) // 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
val banner = bannerService.registerBanner( val banner = bannerService.registerBanner(
characterId = request.characterId, characterId = request.characterId,
imagePath = "" imagePath = "",
lang = request.lang
) )
// 2. 배너 ID를 사용하여 이미지 업로드 // 2. 배너 ID를 사용하여 이미지 업로드

View File

@@ -1,13 +1,15 @@
package kr.co.vividnext.sodalive.admin.chat.dto package kr.co.vividnext.sodalive.admin.chat.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.i18n.Lang
/** /**
* 캐릭터 배너 등록 요청 DTO * 캐릭터 배너 등록 요청 DTO
*/ */
data class ChatCharacterBannerRegisterRequest( data class ChatCharacterBannerRegisterRequest(
// 캐릭터 ID // 캐릭터 ID
@JsonProperty("characterId") val characterId: Long @JsonProperty("characterId") val characterId: Long,
@JsonProperty("lang") val lang: Lang? = null
) )
/** /**

View File

@@ -1,7 +1,11 @@
package kr.co.vividnext.sodalive.chat.character package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.Lang
import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.JoinColumn import javax.persistence.JoinColumn
import javax.persistence.ManyToOne import javax.persistence.ManyToOne
@@ -24,6 +28,10 @@ class ChatCharacterBanner(
// 정렬 순서 (낮을수록 먼저 표시) // 정렬 순서 (낮을수록 먼저 표시)
var sortOrder: Int = 0, var sortOrder: Int = 0,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var lang: Lang = Lang.KO,
// 활성화 여부 (소프트 삭제용) // 활성화 여부 (소프트 삭제용)
var isActive: Boolean = true var isActive: Boolean = true
) : BaseEntity() ) : BaseEntity()

View File

@@ -62,7 +62,7 @@ class ChatCharacterController(
val isAdultAccessible = resolveIsAdultAccessible(member) val isAdultAccessible = resolveIsAdultAccessible(member)
// 배너 조회 (최대 10개) // 배너 조회 (최대 10개)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang)
.content .content
.map { .map {
CharacterBannerResponse( CharacterBannerResponse(

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.repository package kr.co.vividnext.sodalive.chat.character.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
@@ -12,6 +13,8 @@ interface ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Lon
// 활성화된 배너 목록 조회 (정렬 순서대로) // 활성화된 배너 목록 조회 (정렬 순서대로)
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner> fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page<ChatCharacterBanner>
// 활성화된 배너 중 최대 정렬 순서 값 조회 // 활성화된 배너 중 최대 정렬 순서 값 조회
@Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true") @Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true")
fun findMaxSortOrder(): Int? fun findMaxSortOrder(): Int?

View File

@@ -4,6 +4,7 @@ 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.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -21,6 +22,19 @@ class ChatCharacterBannerService(
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
} }
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<ChatCharacterBanner> {
if (lang == Lang.KO) {
return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
val localizedBanners = bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable)
return if (localizedBanners.hasContent()) {
localizedBanners
} else {
bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
}
/** /**
* 배너 상세 조회 * 배너 상세 조회
*/ */
@@ -37,7 +51,10 @@ class ChatCharacterBannerService(
* @return 등록된 배너 * @return 등록된 배너
*/ */
@Transactional @Transactional
fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner { fun registerBanner(characterId: Long, imagePath: String, lang: Lang? = null): ChatCharacterBanner {
val finalLang = lang ?: Lang.KO
validateRegisterLang(finalLang)
val character = characterRepository.findById(characterId) val character = characterRepository.findById(characterId)
.orElseThrow { SodaException(messageKey = "chat.character.not_found") } .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
@@ -51,12 +68,21 @@ class ChatCharacterBannerService(
val banner = ChatCharacterBanner( val banner = ChatCharacterBanner(
imagePath = imagePath, imagePath = imagePath,
chatCharacter = character, chatCharacter = character,
sortOrder = finalSortOrder sortOrder = finalSortOrder,
lang = finalLang
) )
return bannerRepository.save(banner) return bannerRepository.save(banner)
} }
private fun validateRegisterLang(lang: Lang) {
if (lang == Lang.KO || lang == Lang.EN || lang == Lang.JA) {
return
}
throw SodaException(messageKey = "common.error.invalid_request")
}
/** /**
* 배너 수정 * 배너 수정
* *

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.i18n package kr.co.vividnext.sodalive.i18n
import com.fasterxml.jackson.annotation.JsonCreator
import java.util.Locale import java.util.Locale
enum class Lang(val code: String, val locale: Locale) { enum class Lang(val code: String, val locale: Locale) {
@@ -8,6 +9,14 @@ enum class Lang(val code: String, val locale: Locale) {
JA("ja", Locale.JAPANESE); JA("ja", Locale.JAPANESE);
companion object { companion object {
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun fromCode(value: String): Lang {
return values().find {
it.code.equals(value.trim(), ignoreCase = true) || it.name.equals(value.trim(), ignoreCase = true)
} ?: throw IllegalArgumentException("Unknown language code: $value")
}
fun fromAcceptLanguage(header: String?): Lang { fun fromAcceptLanguage(header: String?): Lang {
if (header.isNullOrBlank()) return KO if (header.isNullOrBlank()) return KO
val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리 val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리

View File

@@ -0,0 +1,128 @@
package kr.co.vividnext.sodalive.admin.chat
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
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.mock.web.MockMultipartFile
import java.net.URL
class AdminChatBannerControllerTest {
private val bannerService = Mockito.mock(ChatCharacterBannerService::class.java)
private val adminCharacterService = Mockito.mock(AdminChatCharacterService::class.java)
private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
private val s3Uploader = S3Uploader(amazonS3Client)
private val controller = AdminChatBannerController(
bannerService = bannerService,
adminCharacterService = adminCharacterService,
s3Uploader = s3Uploader,
langContext = LangContext(),
messageSource = SodaMessageSource(),
s3Bucket = "test-bucket",
imageHost = "https://cdn.test"
)
@Test
fun shouldRegisterJapaneseBannerThroughAdminApi() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
val updatedBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
characterId = 1L,
imagePath = "",
lang = Lang.JA
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"characterId\":1,\"lang\":\"ja\"}"
)
assertTrue(response.success)
assertEquals(10L, response.data?.id)
assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/characters/banners/10/") == true)
Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA)
}
@Test
fun shouldDeserializeIso639LanguageCodeToLangEnum() {
val request = ObjectMapper().readValue(
"{\"characterId\":1,\"lang\":\"en\"}",
kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest::class.java
)
assertEquals(Lang.EN, request.lang)
}
@Test
fun shouldRegisterKoreanBannerByDefaultWhenLangIsMissing() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 11L, lang = Lang.KO, imagePath = "")
val updatedBanner = createBanner(id = 11L, lang = Lang.KO, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
characterId = 2L,
imagePath = "",
lang = null
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(11L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"characterId\":2}"
)
assertTrue(response.success)
assertEquals(11L, response.data?.id)
Mockito.verify(bannerService).registerBanner(2L, "", null)
}
private fun createBanner(id: Long, lang: Lang, imagePath: String): 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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}