feat(ai-character): 캐릭터 관리자 API를 추가한다

This commit is contained in:
2026-07-28 01:32:53 +09:00
parent 54bbfc75cf
commit 90c0182ae1
11 changed files with 2539 additions and 0 deletions
@@ -0,0 +1,345 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.sun.net.httpserver.HttpServer
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.endsWith
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import javax.persistence.EntityManager
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val entityManager: EntityManager
) {
@Test
@DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다")
fun shouldNormalizeListPageAndMinimumSize() {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-list-character",
name = "v2-list-character",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "-1")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].name").value("v2-list-character"))
.andExpect(jsonPath("$.data.items[0].creatorProfileImageUrl").doesNotExist())
.andExpect(jsonPath("$.data.items[0].creatorIntroduce").doesNotExist())
.andExpect(jsonPath("$.data.items[0].updatedAtUtc").doesNotExist())
}
@Test
@DisplayName("검색은 최대 size를 50으로 보정한다")
fun shouldNormalizeSearchMaximumSize() {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-search-character",
name = "v2-search-character",
description = "find this character",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("search", "find this")
.param("size", "100")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].externalCharacterId").value("v2-search-character"))
}
@Test
@DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다")
fun shouldReturnDetailForAiCharacter() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-detail-character",
name = "v2-detail-character",
description = "detail description",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.characterId").value(character.id))
.andExpect(jsonPath("$.data.externalCharacterId").value("v2-detail-character"))
.andExpect(jsonPath("$.data.creatorMemberId").value(character.creatorMember!!.id))
.andExpect(jsonPath("$.data.creatorNickname").value("v2-detail-character"))
.andExpect(jsonPath("$.data.creatorProfileImageUrl").value(endsWith("/profile/default-profile.png")))
.andExpect(jsonPath("$.data.creatorIntroduce").value("detail description"))
.andExpect(jsonPath("$.data.isActive").value(true))
.andExpect(jsonPath("$.data.createdAtUtc").value(endsWith("Z")))
.andExpect(jsonPath("$.data.updatedAtUtc").value(endsWith("Z")))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("상세는 AI creatorMember가 아닌 target을 요청 언어의 invalid request로 거부한다")
fun shouldReturnLocalizedErrorForInvalidDetailTarget(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-invalid-target",
name = "v2-invalid-target",
description = "description",
systemPrompt = "prompt"
)
character.creatorMember!!.memberKind = MemberKind.HUMAN
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("목록 binding 오류는 요청 언어의 invalid request를 반환한다")
fun shouldReturnLocalizedErrorForInvalidListBinding(language: String, message: String) {
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "not-a-number")
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
}
@Test
@DisplayName("목록·상세·생성·수정 endpoint는 non-ADMIN JWT를 binding 전에 거부한다")
fun shouldRejectNonAdminJwtRoleForEveryCharacterEndpoint() {
actualCharacterEndpointRequests().forEach { request ->
mockMvc.perform(
request
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(nonAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
}
@Test
@DisplayName("목록·상세·생성·수정 endpoint는 stale ADMIN claim을 binding 전에 거부한다")
fun shouldRejectStaleAdminClaimForEveryCharacterEndpoint() {
actualCharacterEndpointRequests().forEach { request ->
mockMvc.perform(
request
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(staleAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
}
@Test
@DisplayName("실제 상세 endpoint preflight는 캐릭터 관리자 Origin을 허용한다")
fun shouldApplyCorsToDetailEndpointPreflight() {
mockMvc.perform(
options("/api/v2/admin/ai-characters/1")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@CsvSource(
"/api/v2/admin/ai-characters,GET",
"/api/v2/admin/ai-characters/1,GET",
"/api/v2/admin/ai-characters,POST",
"/api/v2/admin/ai-characters/1,PUT"
)
@DisplayName("실제 character endpoint preflight는 캐릭터 관리자 Origin만 허용한다")
fun shouldApplyCorsPreflightToEveryCharacterEndpoint(path: String, method: String) {
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("목록 endpoint는 ADMIN 인증을 상속한다")
fun shouldInheritAdminAuthorizationForListEndpoint() {
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun nonAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "user@example.com",
password = "password",
nickname = "user",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
)
private fun staleAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "stale-admin@example.com",
password = "password",
nickname = "stale-admin",
role = MemberRole.USER
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun actualCharacterEndpointRequests(): List<MockHttpServletRequestBuilder> {
return listOf(
get("/api/v2/admin/ai-characters"),
get("/api/v2/admin/ai-characters/1"),
post("/api/v2/admin/ai-characters"),
put("/api/v2/admin/ai-characters/1")
)
}
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/api/characters") { exchange ->
val response = """{"success":true,"data":{"id":"external-v2-created-character"}}"""
.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE)
exchange.sendResponseHeaders(200, response.size.toLong())
exchange.responseBody.use { it.write(response) }
}
start()
}
@JvmStatic
@DynamicPropertySource
fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) {
registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" }
}
@JvmStatic
@AfterAll
fun stopExternalCharacterApi() {
externalCharacterApi.stop(0)
}
}
}
@@ -0,0 +1,164 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.amazonaws.services.s3.AmazonS3Client
import com.sun.net.httpserver.HttpServer
import kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterController
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.ApplicationEventPublisher
import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import java.net.InetSocketAddress
import java.net.URL
import java.nio.charset.StandardCharsets
import javax.persistence.EntityManager
@SpringBootTest
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest @Autowired constructor(
private val chatCharacterService: ChatCharacterService,
private val adminChatCharacterService: AdminChatCharacterService,
private val originalWorkService: AdminOriginalWorkService,
private val creatorMemberService: ChatCharacterCreatorMemberService,
private val entityManager: EntityManager
) {
@Test
fun `기존 관리자 캐릭터 등록은 설명 언어 감지 이벤트를 발행한다`() {
val server = startExternalCharacterApi()
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val controller = createLegacyController(
s3Uploader = stubS3Uploader("characters/event/profile.png"),
publisher = publisher,
apiUrl = "http://localhost:${server.address.port}"
)
try {
controller.registerCharacter(
image = MockMultipartFile("image", "image.png", "image/png", byteArrayOf(1)),
requestString = """
{
"name":"legacy-character-language-detect",
"systemPrompt":"prompt",
"description":"detect this description",
"age":"21",
"gender":"여성",
"region":"KR",
"tags":[],
"hobbies":[],
"values":[],
"goals":[],
"relationships":[],
"personalities":[],
"backgrounds":[],
"memories":[]
}
""".trimIndent()
)
entityManager.flush()
val saved = chatCharacterService.findByName("legacy-character-language-detect")!!
val event = capturePublishedEvent(publisher) as LanguageDetectEvent
assertEquals(saved.id, event.id)
assertEquals("detect this description", event.query)
assertEquals(LanguageDetectTargetType.CHARACTER, event.targetType)
} finally {
server.stop(0)
}
}
@Test
fun `기존 관리자 캐릭터 수정은 번역 이벤트를 발행한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-translation",
name = "legacy-character-translation",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val controller = createLegacyController(
s3Uploader = stubS3Uploader("unused.png"),
publisher = publisher,
apiUrl = "http://localhost"
)
controller.updateCharacter(
image = null,
requestString = """
{
"id":${character.id},
"originalTitle":"changed original"
}
""".trimIndent()
)
val event = capturePublishedEvent(publisher) as LanguageTranslationEvent
assertEquals(character.id, event.id)
assertEquals(LanguageTranslationTargetType.CHARACTER, event.targetType)
assertTrue(event.waitTransactionCommit)
}
private fun createLegacyController(
s3Uploader: S3Uploader,
publisher: ApplicationEventPublisher,
apiUrl: String
): AdminChatCharacterController {
return AdminChatCharacterController(
service = chatCharacterService,
adminService = adminChatCharacterService,
s3Uploader = s3Uploader,
originalWorkService = originalWorkService,
creatorMemberService = creatorMemberService,
applicationEventPublisher = publisher,
apiKey = "test-api-key",
apiUrl = apiUrl,
s3Bucket = "test-bucket",
imageHost = "https://cdn.example.com"
)
}
private fun stubS3Uploader(path: String): S3Uploader {
val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://cdn.example.com/$path"))
return S3Uploader(amazonS3Client)
}
private fun startExternalCharacterApi(): HttpServer {
val server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/api/characters") { exchange ->
val response = """{"success":true,"data":{"id":"external-event-character"}}"""
.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(200, response.size.toLong())
exchange.responseBody.use { it.write(response) }
}
server.start()
return server
}
private fun capturePublishedEvent(publisher: ApplicationEventPublisher): Any {
val captor = ArgumentCaptor.forClass(Any::class.java)
Mockito.verify(publisher).publishEvent(captor.capture())
return captor.value
}
}
@@ -0,0 +1,220 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
@SpringBootTest
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class LegacyChatCharacterAdminCharacterizationTest @Autowired constructor(
private val chatCharacterService: ChatCharacterService,
private val originalWorkService: AdminOriginalWorkService,
private val chatCharacterRepository: ChatCharacterRepository,
private val memberRepository: MemberRepository,
private val entityManager: EntityManager
) {
@Test
fun `기존 관리자 캐릭터 생성은 AI creatorMember와 상세 정보를 함께 저장한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-1",
name = "legacy-character-create",
description = "legacy description",
systemPrompt = "system prompt",
age = 21,
gender = "여성",
mbti = "INFP",
speechPattern = "polite",
speechStyle = "soft",
appearance = "silver hair",
originalTitle = "original title",
originalLink = "https://example.com/original",
characterType = CharacterType.Character,
region = "KR",
tags = listOf("tag-a", "tag-a", "tag-b"),
values = listOf("value-a"),
hobbies = listOf("hobby-a"),
goals = listOf("goal-a"),
memories = listOf(Triple("memory", "content", "happy")),
personalities = listOf(Pair("kind", "kind desc"))
)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
val creatorMember = saved.creatorMember
assertNotNull(creatorMember)
assertEquals(MemberRole.CREATOR, creatorMember!!.role)
assertEquals(MemberKind.AI_CHARACTER, creatorMember.memberKind)
assertEquals("legacy-character-create", creatorMember.nickname)
assertEquals("legacy description", creatorMember.introduce)
assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted())
assertEquals(listOf("value-a"), saved.valueMappings.map { it.value.value })
assertEquals(listOf("hobby-a"), saved.hobbyMappings.map { it.hobby.hobby })
assertEquals(listOf("goal-a"), saved.goalMappings.map { it.goal.goal })
assertEquals(listOf("memory"), saved.memories.map { it.title })
assertEquals(listOf("kind"), saved.personalities.map { it.trait })
}
@Test
fun `기존 관리자 원작 배정은 캐릭터 originalWork 연결을 저장한다`() {
val originalWork = saveOriginalWork("legacy-original-work")
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-original",
name = "legacy-character-original-work",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
assertEquals(originalWork.id, saved.originalWork!!.id)
assertEquals("legacy-original-work", saved.originalWork!!.title)
}
@Test
fun `기존 관리자 캐릭터 수정은 변경 필드와 AI creatorMember 표시 정보를 동기화한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-2",
name = "legacy-character-update",
description = "before description",
systemPrompt = "before prompt",
tags = listOf("old-tag"),
memories = listOf(Triple("old-memory", "old content", "sad"))
)
entityManager.flush()
chatCharacterService.updateChatCharacterWithDetails(
imagePath = "characters/${character.id}/profile.png",
request = ChatCharacterUpdateRequest(
id = character.id!!,
name = "legacy-character-updated",
description = "after description",
age = "24",
tags = listOf("new-tag"),
memories = listOf(ChatCharacterMemoryRequest("new-memory", "new content", "calm")),
personalities = listOf(ChatCharacterPersonalityRequest("calm", "calm desc"))
)
)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow()
assertEquals("legacy-character-updated", saved.name)
assertEquals("after description", saved.description)
assertEquals(24, saved.age)
assertEquals("characters/${character.id}/profile.png", saved.imagePath)
assertEquals(listOf("new-tag"), saved.tagMappings.map { it.tag.tag })
assertEquals(listOf("new-memory"), saved.memories.map { it.title })
assertEquals(listOf("calm"), saved.personalities.map { it.trait })
assertEquals("legacy-character-updated", creatorMember.nickname)
assertEquals("characters/${character.id}/profile.png", creatorMember.profileImage)
assertEquals("after description", creatorMember.introduce)
}
@Test
fun `기존 관리자 캐릭터 비활성화는 row와 creatorMember를 남기고 isActive false로 저장한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-3",
name = "legacy-character-disable",
description = "disable description",
systemPrompt = "prompt"
)
val creatorMemberId = character.creatorMember!!.id!!
val audioContent = saveAudioContent(character.creatorMember!!)
entityManager.flush()
chatCharacterService.updateChatCharacterWithDetails(
request = ChatCharacterUpdateRequest(
id = character.id!!,
name = character.name,
isActive = false
)
)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
assertFalse(saved.isActive)
assertTrue(saved.name.startsWith("inactive_legacy-character-disable_"))
assertTrue(memberRepository.existsById(creatorMemberId))
assertEquals(creatorMemberId, saved.creatorMember!!.id)
assertNotNull(entityManager.find(AudioContent::class.java, audioContent.id!!))
assertEquals(creatorMemberId, entityManager.find(AudioContent::class.java, audioContent.id!!).member!!.id)
}
@Test
fun `기존 관리자 중복 이름 검증은 findByName 조회 결과에 의존한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-4",
name = "legacy-duplicate-name",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
entityManager.clear()
val found = chatCharacterService.findByName("legacy-duplicate-name")
assertEquals(character.id, found!!.id)
}
private fun saveOriginalWork(title: String): OriginalWork {
val originalWork = OriginalWork(
title = title,
contentType = "webtoon",
category = "fantasy",
isAdult = false,
description = "description"
)
entityManager.persist(originalWork)
return originalWork
}
private fun saveAudioContent(creator: Member): AudioContent {
val theme = AudioContentTheme(theme = "legacy-theme-${creator.id}", image = "theme.png", isActive = true)
entityManager.persist(theme)
val content = AudioContent(
title = "legacy-audio-${creator.id}",
detail = "detail",
languageCode = "ko"
)
content.member = creator
content.theme = theme
content.isActive = true
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
}