feat(ai-character): 캐릭터 관리자 API를 추가한다
This commit is contained in:
+1114
File diff suppressed because it is too large
Load Diff
+345
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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
|
||||
}
|
||||
}
|
||||
+220
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user