feat(chat-banner): 다국어 캐릭터 배너 등록과 노출을 지원한다
배너를 언어별로 저장하고 요청 언어 우선 조회 후 한국어로 fallback 하도록 맞춘다.
This commit is contained in:
41
docs/20260402_chat_character_banner_lang_ddl.sql
Normal file
41
docs/20260402_chat_character_banner_lang_ddl.sql
Normal 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;
|
||||||
25
docs/20260402_일본어채팅캐릭터배너추가.md
Normal file
25
docs/20260402_일본어채팅캐릭터배너추가.md
Normal 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`는 수행하지 못했다.
|
||||||
@@ -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를 사용하여 이미지 업로드
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배너 수정
|
* 배너 수정
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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자리만 보내지만 안전하게 처리
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user