diff --git a/docs/20260402_chat_character_banner_lang_ddl.sql b/docs/20260402_chat_character_banner_lang_ddl.sql new file mode 100644 index 00000000..1c5ffebc --- /dev/null +++ b/docs/20260402_chat_character_banner_lang_ddl.sql @@ -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; diff --git a/docs/20260402_일본어채팅캐릭터배너추가.md b/docs/20260402_일본어채팅캐릭터배너추가.md new file mode 100644 index 00000000..d1e6011e --- /dev/null +++ b/docs/20260402_일본어채팅캐릭터배너추가.md @@ -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`는 수행하지 못했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 086ab3ed..1c70040e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -127,7 +127,8 @@ class AdminChatBannerController( // 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함) val banner = bannerService.registerBanner( characterId = request.characterId, - imagePath = "" + imagePath = "", + lang = request.lang ) // 2. 배너 ID를 사용하여 이미지 업로드 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt index 930c8e13..4d1877fd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt @@ -1,13 +1,15 @@ package kr.co.vividnext.sodalive.admin.chat.dto import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.i18n.Lang /** * 캐릭터 배너 등록 요청 DTO */ data class ChatCharacterBannerRegisterRequest( // 캐릭터 ID - @JsonProperty("characterId") val characterId: Long + @JsonProperty("characterId") val characterId: Long, + @JsonProperty("lang") val lang: Lang? = null ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt index 055f3a17..c2a85bb5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt @@ -1,7 +1,11 @@ package kr.co.vividnext.sodalive.chat.character 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.EnumType +import javax.persistence.Enumerated import javax.persistence.FetchType import javax.persistence.JoinColumn import javax.persistence.ManyToOne @@ -24,6 +28,10 @@ class ChatCharacterBanner( // 정렬 순서 (낮을수록 먼저 표시) var sortOrder: Int = 0, + @Column(nullable = false) + @Enumerated(EnumType.STRING) + var lang: Lang = Lang.KO, + // 활성화 여부 (소프트 삭제용) var isActive: Boolean = true ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index f2d21ffe..cdaf7995 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -62,7 +62,7 @@ class ChatCharacterController( val isAdultAccessible = resolveIsAdultAccessible(member) // 배너 조회 (최대 10개) - val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) + val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang) .content .map { CharacterBannerResponse( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt index 2de90207..4a911059 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.repository 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.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -12,6 +13,8 @@ interface ChatCharacterBannerRepository : JpaRepository + fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page + // 활성화된 배너 중 최대 정렬 순서 값 조회 @Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true") fun findMaxSortOrder(): Int? diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt index 6321c28d..40858642 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -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.ChatCharacterRepository 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.Pageable import org.springframework.stereotype.Service @@ -21,6 +22,19 @@ class ChatCharacterBannerService( return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) } + fun getDisplayBanners(pageable: Pageable, lang: Lang): Page { + 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 등록된 배너 */ @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) .orElseThrow { SodaException(messageKey = "chat.character.not_found") } @@ -51,12 +68,21 @@ class ChatCharacterBannerService( val banner = ChatCharacterBanner( imagePath = imagePath, chatCharacter = character, - sortOrder = finalSortOrder + sortOrder = finalSortOrder, + lang = finalLang ) 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") + } + /** * 배너 수정 * diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt index d40abe32..a1ceea97 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.i18n +import com.fasterxml.jackson.annotation.JsonCreator import java.util.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); 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 { if (header.isNullOrBlank()) return KO val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt new file mode 100644 index 00000000..cbe64582 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterControllerTest.kt new file mode 100644 index 00000000..bc949f1e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterControllerTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceTest.kt new file mode 100644 index 00000000..8475c57f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceTest.kt @@ -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(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 + } +}