feat(ai-character): 캐릭터 관리자 레거시 계약을 보강한다
This commit is contained in:
+597
-70
@@ -31,6 +31,7 @@ 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.junit.jupiter.params.provider.ValueSource
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -43,6 +44,7 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.mock.web.MockPart
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
@@ -55,6 +57,8 @@ import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.ResultActions
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||
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.Propagation
|
||||
@@ -97,11 +101,13 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
externalRequestCount.set(0)
|
||||
Mockito.reset(amazonS3Client)
|
||||
Mockito.reset(applicationEventPublisher)
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(java.net.URL("https", "cdn.example.com", "/image.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 원작과 AI creatorMember 표시 정보를 만든다")
|
||||
fun shouldCreateCharacterAndReturnDetailWithAiCreatorMember() {
|
||||
@DisplayName("생성은 필수 image와 레거시 전체 request를 반영하고 data null을 반환한다")
|
||||
fun shouldCreateCharacterFromLegacyRequestAndReturnNullData() {
|
||||
val originalWork = OriginalWork(
|
||||
title = "v2-original-work",
|
||||
contentType = "webtoon",
|
||||
@@ -113,32 +119,48 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"v2-created-character",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"created description",
|
||||
"region":"KR",
|
||||
"age":"21",
|
||||
"gender":"여성",
|
||||
"mbti":"INFP",
|
||||
"speechPattern":"polite",
|
||||
"speechStyle":"soft",
|
||||
"appearance":"silver hair",
|
||||
"region":"JP",
|
||||
"originalTitle":"legacy title",
|
||||
"originalLink":"original-link",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"characterType":"Character"
|
||||
"characterType":"Clone",
|
||||
"tags":["tag-a","tag-b"],
|
||||
"hobbies":["reading"],
|
||||
"values":["honesty"],
|
||||
"goals":["friendship"],
|
||||
"relationships":[{
|
||||
"personName":"Mina",
|
||||
"relationshipName":"friend",
|
||||
"description":"best friend",
|
||||
"importance":10,
|
||||
"relationshipType":"ALLY",
|
||||
"currentStatus":"ACTIVE"
|
||||
}],
|
||||
"personalities":[{"trait":"kind","description":"kind description"}],
|
||||
"backgrounds":[{"topic":"hometown","description":"moon city"}],
|
||||
"memories":[{"title":"memory title","content":"memory content","emotion":"happy"}]
|
||||
}
|
||||
""".trimIndent().toByteArray(StandardCharsets.UTF_8)
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.name").value("v2-created-character"))
|
||||
.andExpect(jsonPath("$.data.externalCharacterId").value("external-v2-created-character"))
|
||||
.andExpect(jsonPath("$.data.creatorNickname").value("v2-created-character"))
|
||||
.andExpect(jsonPath("$.data.originalWorkId").value(originalWork.id))
|
||||
.andExpect(jsonPath("$.data.isActive").value(true))
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
@@ -150,6 +172,160 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
assertEquals("v2-created-character", creatorMember.nickname)
|
||||
assertEquals("created description", creatorMember.introduce)
|
||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
||||
assertEquals(21, saved.age)
|
||||
assertEquals("여성", saved.gender)
|
||||
assertEquals("INFP", saved.mbti)
|
||||
assertEquals("polite", saved.speechPattern)
|
||||
assertEquals("soft", saved.speechStyle)
|
||||
assertEquals("silver hair", saved.appearance)
|
||||
assertEquals("JP", saved.region)
|
||||
assertEquals("legacy title", saved.originalTitle)
|
||||
assertEquals("original-link", saved.originalLink)
|
||||
assertEquals("Clone", saved.characterType.name)
|
||||
assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted())
|
||||
assertEquals(listOf("reading"), saved.hobbyMappings.map { it.hobby.hobby })
|
||||
assertEquals(listOf("honesty"), saved.valueMappings.map { it.value.value })
|
||||
assertEquals(listOf("friendship"), saved.goalMappings.map { it.goal.goal })
|
||||
assertEquals(listOf("Mina"), saved.relationships.map { it.personName })
|
||||
assertEquals(listOf("kind"), saved.personalities.map { it.trait })
|
||||
assertEquals(listOf("hometown"), saved.backgrounds.map { it.topic })
|
||||
assertEquals(listOf("memory title"), saved.memories.map { it.title })
|
||||
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 image part를 필수로 요구한다")
|
||||
fun shouldRequireImageForCreate() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"v2-missing-required-image",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description"
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
assertNull(chatCharacterRepository.findByName("v2-missing-required-image"))
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 빈 필수 image를 외부 API 호출 전에 거부한다")
|
||||
fun shouldRejectEmptyCreateImageBeforeSideEffects() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(MockMultipartFile("image", "empty.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf()))
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"v2-empty-required-image",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description"
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
assertNull(chatCharacterRepository.findByName("v2-empty-required-image"))
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("생성은 관계 importance 누락을 외부 API 호출 전에 거부한다")
|
||||
fun shouldRejectMissingRelationshipImportanceBeforeSideEffects() {
|
||||
val characterName = "v2-missing-relationship-importance"
|
||||
val originalWork = createOriginalWork("$characterName-original-work")
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"$characterName",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"relationships":[{
|
||||
"personName":"Mina",
|
||||
"relationshipName":"friend",
|
||||
"description":"best friend",
|
||||
"relationshipType":"ALLY",
|
||||
"currentStatus":"ACTIVE"
|
||||
}]
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("생성은 관계 importance null을 외부 API 호출 전에 거부한다")
|
||||
fun shouldRejectNullRelationshipImportanceBeforeSideEffects() {
|
||||
val characterName = "v2-null-relationship-importance"
|
||||
val originalWork = createOriginalWork("$characterName-original-work")
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"$characterName",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"relationships":[{
|
||||
"personName":"Mina",
|
||||
"relationshipName":"friend",
|
||||
"description":"best friend",
|
||||
"importance":null,
|
||||
"relationshipType":"ALLY",
|
||||
"currentStatus":"ACTIVE"
|
||||
}]
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,7 +333,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldPublishLanguageDetectEventOnCreate() {
|
||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
val response = createFacadeForEventPayloadAssertion(publisher).create(
|
||||
createFacadeForEventPayloadAssertion(publisher).create(
|
||||
image = null,
|
||||
requestString = """
|
||||
{
|
||||
@@ -171,7 +347,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
||||
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
|
||||
val event = eventCaptor.value as LanguageDetectEvent
|
||||
assertEquals(response.characterId, event.id)
|
||||
assertEquals(chatCharacterRepository.findByName("v2-language-detect-event")!!.id, event.id)
|
||||
assertEquals("event description", event.query)
|
||||
}
|
||||
|
||||
@@ -185,11 +361,12 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
)
|
||||
@@ -223,7 +400,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
)
|
||||
@@ -235,11 +412,271 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"POST,ko,잘못된 요청입니다.",
|
||||
"POST,en,Invalid request.",
|
||||
"POST,ja,無効なリクエストです。",
|
||||
"PUT,ko,잘못된 요청입니다.",
|
||||
"PUT,en,Invalid request.",
|
||||
"PUT,ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("생성·수정은 미정의 multipart part를 지역화된 400과 부작용 없음으로 거부한다")
|
||||
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(method: String, language: String, message: String) {
|
||||
val characterName = "v2-undefined-part-$method-$language"
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = characterName,
|
||||
name = characterName,
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entityManager.flush()
|
||||
externalRequestCount.set(0)
|
||||
val request = """
|
||||
{
|
||||
"name":"$characterName",
|
||||
"description":"after description",
|
||||
"systemPrompt":"prompt"
|
||||
}
|
||||
"""
|
||||
val unexpectedPart = MockMultipartFile(
|
||||
"unexpected",
|
||||
"unexpected.txt",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
"unexpected".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(requestPart(request))
|
||||
.file(unexpectedPart)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.file(requestPart(request))
|
||||
.file(unexpectedPart)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
|
||||
entityManager.clear()
|
||||
if (character == null) {
|
||||
assertNull(chatCharacterRepository.findByName(characterName))
|
||||
} else {
|
||||
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
|
||||
}
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"POST,ko,잘못된 요청입니다.",
|
||||
"POST,en,Invalid request.",
|
||||
"POST,ja,無効なリクエストです。",
|
||||
"PUT,ko,잘못된 요청입니다.",
|
||||
"PUT,en,Invalid request.",
|
||||
"PUT,ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("생성·수정은 filename 없는 미정의 multipart part를 mutation 전 400으로 거부한다")
|
||||
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(
|
||||
method: String,
|
||||
language: String,
|
||||
message: String
|
||||
) {
|
||||
val characterName = "v2-filename-less-part-$method-$language"
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = characterName,
|
||||
name = characterName,
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entityManager.flush()
|
||||
externalRequestCount.set(0)
|
||||
val request = """
|
||||
{
|
||||
"name":"$characterName",
|
||||
"description":"after description",
|
||||
"systemPrompt":"prompt"
|
||||
}
|
||||
"""
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(requestPart(request))
|
||||
.part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8)))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.file(requestPart(request))
|
||||
.part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8)))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
|
||||
entityManager.clear()
|
||||
if (character == null) {
|
||||
assertNull(chatCharacterRepository.findByName(characterName))
|
||||
} else {
|
||||
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
|
||||
}
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
value = [
|
||||
"POST,text/plain,ko,잘못된 요청입니다.",
|
||||
"POST,text/plain,en,Invalid request.",
|
||||
"POST,text/plain,ja,無効なリクエストです。",
|
||||
"POST,<missing>,ko,잘못된 요청입니다.",
|
||||
"POST,<missing>,en,Invalid request.",
|
||||
"POST,<missing>,ja,無効なリクエストです。",
|
||||
"PUT,text/plain,ko,잘못된 요청입니다.",
|
||||
"PUT,text/plain,en,Invalid request.",
|
||||
"PUT,text/plain,ja,無効なリクエストです。",
|
||||
"PUT,<missing>,ko,잘못된 요청입니다.",
|
||||
"PUT,<missing>,en,Invalid request.",
|
||||
"PUT,<missing>,ja,無効なリクエストです。"
|
||||
],
|
||||
nullValues = ["<missing>"]
|
||||
)
|
||||
@DisplayName("생성·수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
|
||||
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
|
||||
method: String,
|
||||
requestContentType: String?,
|
||||
language: String,
|
||||
message: String
|
||||
) {
|
||||
val characterName = "v2-non-json-$method-${requestContentType ?: "missing"}-$language"
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = characterName,
|
||||
name = characterName,
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val request = """
|
||||
{
|
||||
"name":"$characterName",
|
||||
"description":"after description",
|
||||
"systemPrompt":"prompt"
|
||||
}
|
||||
"""
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(requestPart(request, requestContentType))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.file(requestPart(request, requestContentType))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isUnsupportedMediaType)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||
|
||||
entityManager.clear()
|
||||
if (character == null) {
|
||||
assertNull(chatCharacterRepository.findByName(characterName))
|
||||
} else {
|
||||
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
|
||||
}
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["POST", "PUT"])
|
||||
@DisplayName("생성·수정은 request part 누락을 기존 400으로 유지한다")
|
||||
fun shouldKeepMissingRequestPartAsBadRequest(method: String) {
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-missing-request-part",
|
||||
name = "v2-missing-request-part",
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다")
|
||||
fun shouldRejectServerOwnedCreateFields() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -268,6 +705,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldRejectInvalidCharacterTypeBeforeExternalApi() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -304,6 +742,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -330,6 +769,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldRejectMissingOriginalWorkBeforeExternalApi() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -362,6 +802,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -430,7 +871,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다")
|
||||
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성과 S3 호출만 남긴다")
|
||||
fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) {
|
||||
val characterName = "v2-create-event-failure-$language"
|
||||
val originalWork = createOriginalWork("v2-create-event-original-work-$language")
|
||||
@@ -442,6 +883,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
try {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -466,7 +908,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
assertEquals(1, externalRequestCount.get())
|
||||
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java))
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||
}
|
||||
|
||||
@@ -487,6 +929,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -547,12 +990,64 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정의 isActive false는 다른 변경이나 이미지를 함께 받지 않는다")
|
||||
fun shouldRejectMixedSoftDeleteBeforeExternalAndImageSideEffects() {
|
||||
@DisplayName("수정의 isActive false는 다른 optional JSON 필드를 허용하고 비활성화만 반영한다")
|
||||
fun shouldIgnoreOtherFieldsInMixedSoftDeleteRequest() {
|
||||
val originalWork = createOriginalWork("v2-mixed-soft-delete-original")
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-mixed-soft-delete",
|
||||
name = "v2-mixed-soft-delete",
|
||||
description = "before description",
|
||||
systemPrompt = "before prompt",
|
||||
tags = listOf("before-tag")
|
||||
)
|
||||
originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!)
|
||||
entityManager.flush()
|
||||
externalRequestCount.set(0)
|
||||
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"isActive":false,
|
||||
"systemPrompt":"ignored prompt",
|
||||
"description":"ignored description",
|
||||
"age":"99",
|
||||
"characterType":"Clone",
|
||||
"originalWorkId":999999,
|
||||
"tags":["ignored-tag"]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||
|
||||
assertEquals(1, externalRequestCount.get())
|
||||
assertFalse(saved.isActive)
|
||||
assertEquals("before description", saved.description)
|
||||
assertEquals("before prompt", saved.systemPrompt)
|
||||
assertNull(saved.age)
|
||||
assertEquals("Character", saved.characterType.name)
|
||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
||||
assertEquals(listOf("before-tag"), saved.tagMappings.map { it.tag.tag })
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정의 isActive true 단독 요청은 레거시처럼 유효한 no-op mutation이다")
|
||||
fun shouldAcceptIsActiveTrueOnlyUpdateAsLegacyNoOp() {
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-is-active-true-only",
|
||||
name = "v2-is-active-true-only",
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
entityManager.flush()
|
||||
@@ -560,24 +1055,18 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"isActive":false,
|
||||
"description":"ignored description"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.file(requestPart("""{"isActive":true}"""))
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
assertTrue(chatCharacterRepository.findById(character.id!!).orElseThrow().isActive)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||
assertTrue(saved.isActive)
|
||||
assertEquals("before description", saved.description)
|
||||
assertEquals(1, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@@ -659,6 +1148,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.clear()
|
||||
assertEquals(
|
||||
@@ -669,20 +1159,21 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정은 이미지와 표시 정보를 동기화하고 flush된 updatedAtUtc를 반환한다")
|
||||
fun shouldUpdateCharacterImageAndCreatorDisplayFieldsWithFlushedTimestamp() {
|
||||
@DisplayName("수정은 레거시 optional 필드를 반영하고 data null을 반환한다")
|
||||
fun shouldUpdateCharacterFromLegacyOptionalFieldsAndReturnNullData() {
|
||||
val originalWork = createOriginalWork("v2-update-original-work")
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-character-update",
|
||||
name = "v2-character-update",
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
systemPrompt = "before prompt",
|
||||
tags = listOf("before-tag")
|
||||
)
|
||||
entityManager.flush()
|
||||
val beforeUpdatedAt = character.updatedAt!!
|
||||
Thread.sleep(5)
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(java.net.URL("https://cdn.example.com/characters/updated.png"))
|
||||
|
||||
val updateResponse = mockMvc.perform(
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
||||
.file(
|
||||
@@ -690,7 +1181,33 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
"""
|
||||
{
|
||||
"name":"v2-character-updated",
|
||||
"description":"after description"
|
||||
"systemPrompt":"after prompt",
|
||||
"description":"after description",
|
||||
"age":"29",
|
||||
"gender":"여성",
|
||||
"mbti":"ENTJ",
|
||||
"speechPattern":"direct",
|
||||
"speechStyle":"formal",
|
||||
"appearance":"black hair",
|
||||
"originalTitle":"updated title",
|
||||
"originalLink":"updated-link",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"characterType":"Clone",
|
||||
"tags":["after-tag"],
|
||||
"hobbies":["running"],
|
||||
"values":["courage"],
|
||||
"goals":["success"],
|
||||
"relationships":[{
|
||||
"personName":"Jin",
|
||||
"relationshipName":"rival",
|
||||
"description":"friendly rival",
|
||||
"importance":7,
|
||||
"relationshipType":"RIVAL",
|
||||
"currentStatus":"ACTIVE"
|
||||
}],
|
||||
"personalities":[{"trait":"bold","description":"bold description"}],
|
||||
"backgrounds":[{"topic":"school","description":"academy"}],
|
||||
"memories":[{"title":"victory","content":"first win","emotion":"proud"}]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -698,31 +1215,34 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.name").value("v2-character-updated"))
|
||||
.andExpect(jsonPath("$.data.creatorNickname").value("v2-character-updated"))
|
||||
.andExpect(jsonPath("$.data.creatorIntroduce").value("after description"))
|
||||
.andReturn()
|
||||
val updateTimestamp = objectMapper.readTree(updateResponse.response.contentAsString)
|
||||
.path("data")
|
||||
.path("updatedAtUtc")
|
||||
.asText()
|
||||
|
||||
val detailResponse = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
val detailTimestamp = objectMapper.readTree(detailResponse.response.contentAsString)
|
||||
.path("data")
|
||||
.path("updatedAtUtc")
|
||||
.asText()
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.clear()
|
||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||
val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow()
|
||||
|
||||
assertEquals(detailTimestamp, updateTimestamp)
|
||||
assertEquals("v2-character-updated", saved.name)
|
||||
assertEquals("after prompt", saved.systemPrompt)
|
||||
assertEquals("after description", saved.description)
|
||||
assertEquals(29, saved.age)
|
||||
assertEquals("여성", saved.gender)
|
||||
assertEquals("ENTJ", saved.mbti)
|
||||
assertEquals("direct", saved.speechPattern)
|
||||
assertEquals("formal", saved.speechStyle)
|
||||
assertEquals("black hair", saved.appearance)
|
||||
assertEquals("updated title", saved.originalTitle)
|
||||
assertEquals("updated-link", saved.originalLink)
|
||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
||||
assertEquals("Clone", saved.characterType.name)
|
||||
assertEquals(listOf("after-tag"), saved.tagMappings.map { it.tag.tag })
|
||||
assertEquals(listOf("running"), saved.hobbyMappings.map { it.hobby.hobby })
|
||||
assertEquals(listOf("courage"), saved.valueMappings.map { it.value.value })
|
||||
assertEquals(listOf("success"), saved.goalMappings.map { it.goal.goal })
|
||||
assertEquals(listOf("Jin"), saved.relationships.map { it.personName })
|
||||
assertEquals(listOf("bold"), saved.personalities.map { it.trait })
|
||||
assertEquals(listOf("school"), saved.backgrounds.map { it.topic })
|
||||
assertEquals(listOf("victory"), saved.memories.map { it.title })
|
||||
assertTrue(saved.updatedAt!!.isAfter(beforeUpdatedAt))
|
||||
assertEquals(saved.imagePath, creatorMember.profileImage)
|
||||
assertEquals(saved.name, creatorMember.nickname)
|
||||
assertEquals(saved.description, creatorMember.introduce)
|
||||
@@ -872,14 +1392,14 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"""{"isActive":false}""".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.isActive").value(false))
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
@@ -907,15 +1427,19 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
)
|
||||
)
|
||||
|
||||
private fun requestPart(request: String): MockMultipartFile {
|
||||
private fun requestPart(request: String, contentType: String? = MediaType.APPLICATION_JSON_VALUE): MockMultipartFile {
|
||||
return MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
contentType,
|
||||
request.trimIndent().toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImagePart(): MockMultipartFile {
|
||||
return MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))
|
||||
}
|
||||
|
||||
private fun saveAudioContent(creator: Member): AudioContent {
|
||||
val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
@@ -1050,7 +1574,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
characterMapper = characterMapper,
|
||||
originalWorkService = originalWorkService,
|
||||
creatorMemberService = creatorMemberService,
|
||||
applicationEventPublisher = publisher
|
||||
applicationEventPublisher = publisher,
|
||||
imageHost = "https://cdn.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1081,6 +1606,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val NULL_SUCCESS_RESPONSE =
|
||||
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||
private val externalRequestCount = AtomicInteger()
|
||||
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||
createContext("/api/characters") { exchange ->
|
||||
|
||||
+167
-32
@@ -1,14 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
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.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
@@ -46,84 +50,215 @@ import javax.persistence.EntityManager
|
||||
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다")
|
||||
fun shouldNormalizeListPageAndMinimumSize() {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
@DisplayName("목록은 레거시 totalCount와 content의 exact JSON 필드를 반환한다")
|
||||
fun shouldReturnLegacyListContract() {
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-list-character",
|
||||
name = "v2-list-character",
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
systemPrompt = "prompt",
|
||||
age = 21,
|
||||
gender = "여성",
|
||||
mbti = "INFP",
|
||||
speechPattern = "polite",
|
||||
speechStyle = "soft",
|
||||
tags = listOf("list-tag")
|
||||
)
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
val response = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters")
|
||||
.param("page", "-1")
|
||||
.param("page", "0")
|
||||
.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())
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
val item = data.path("content").path(0)
|
||||
|
||||
assertEquals(setOf("totalCount", "content"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"name",
|
||||
"imageUrl",
|
||||
"description",
|
||||
"gender",
|
||||
"age",
|
||||
"mbti",
|
||||
"speechStyle",
|
||||
"speechPattern",
|
||||
"region",
|
||||
"tags",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
),
|
||||
item.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals(character.id, item.path("id").asLong())
|
||||
assertEquals("v2-list-character", item.path("name").asText())
|
||||
assertEquals(listOf("list-tag"), item.path("tags").map { it.asText() })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("검색은 최대 size를 50으로 보정한다")
|
||||
fun shouldNormalizeSearchMaximumSize() {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
@DisplayName("목록은 OpenAPI minimum인 size 1을 그대로 적용한다")
|
||||
fun shouldHonorLegacyMinimumListSize() {
|
||||
repeat(2) { index ->
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-list-size-$index",
|
||||
name = "v2-list-size-$index",
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
}
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters")
|
||||
.param("size", "1")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(2))
|
||||
.andExpect(jsonPath("$.data.content.length()").value(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("검색은 레거시 searchTerm query를 사용한다")
|
||||
fun shouldSearchByLegacySearchTerm() {
|
||||
val matchingCharacter = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-search-character",
|
||||
name = "v2-search-character",
|
||||
description = "find this character",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-non-matching-character",
|
||||
name = "v2-non-matching-character",
|
||||
description = "different description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters")
|
||||
.param("search", "find this")
|
||||
.param("searchTerm", "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"))
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(matchingCharacter.id))
|
||||
.andExpect(jsonPath("$.data.content[0].name").value("v2-search-character"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다")
|
||||
fun shouldReturnDetailForAiCharacter() {
|
||||
@DisplayName("상세는 레거시 전체 필드와 nested 배열을 exact JSON으로 반환한다")
|
||||
fun shouldReturnLegacyDetailContract() {
|
||||
val originalWork = OriginalWork(
|
||||
title = "detail original work",
|
||||
contentType = "webtoon",
|
||||
category = "fantasy",
|
||||
isAdult = false,
|
||||
description = "original work description"
|
||||
)
|
||||
entityManager.persist(originalWork)
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-detail-character",
|
||||
name = "v2-detail-character",
|
||||
description = "detail description",
|
||||
systemPrompt = "prompt"
|
||||
systemPrompt = "detail prompt",
|
||||
age = 22,
|
||||
gender = "여성",
|
||||
mbti = "ENFP",
|
||||
speechPattern = "bright",
|
||||
speechStyle = "friendly",
|
||||
appearance = "silver hair",
|
||||
region = "JP",
|
||||
tags = listOf("detail-tag"),
|
||||
hobbies = listOf("reading"),
|
||||
values = listOf("honesty"),
|
||||
goals = listOf("friendship"),
|
||||
memories = listOf(Triple("memory title", "memory content", "happy")),
|
||||
personalities = listOf(Pair("kind", "kind description")),
|
||||
backgrounds = listOf(Pair("hometown", "moon city")),
|
||||
relationships = listOf(
|
||||
ChatCharacterRelationshipRequest(
|
||||
personName = "Mina",
|
||||
relationshipName = "friend",
|
||||
description = "best friend",
|
||||
importance = 10,
|
||||
relationshipType = "ALLY",
|
||||
currentStatus = "ACTIVE"
|
||||
)
|
||||
)
|
||||
)
|
||||
character.imagePath = "characters/${character.id}/detail.png"
|
||||
character.originalWork = originalWork
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
val response = 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")))
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"characterUUID",
|
||||
"name",
|
||||
"imageUrl",
|
||||
"description",
|
||||
"systemPrompt",
|
||||
"characterType",
|
||||
"age",
|
||||
"gender",
|
||||
"mbti",
|
||||
"speechPattern",
|
||||
"speechStyle",
|
||||
"appearance",
|
||||
"region",
|
||||
"isActive",
|
||||
"tags",
|
||||
"hobbies",
|
||||
"values",
|
||||
"goals",
|
||||
"relationships",
|
||||
"personalities",
|
||||
"backgrounds",
|
||||
"memories",
|
||||
"originalWork"
|
||||
),
|
||||
data.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals(character.id, data.path("id").asLong())
|
||||
assertEquals("v2-detail-character", data.path("characterUUID").asText())
|
||||
assertEquals("detail prompt", data.path("systemPrompt").asText())
|
||||
assertEquals("Character", data.path("characterType").asText())
|
||||
assertEquals(listOf("detail-tag"), data.path("tags").map { it.asText() })
|
||||
assertEquals(
|
||||
setOf("personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"),
|
||||
data.path("relationships").path(0).fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals("Mina", data.path("relationships").path(0).path("personName").asText())
|
||||
assertEquals(setOf("trait", "description"), data.path("personalities").path(0).fieldNames().asSequence().toSet())
|
||||
assertEquals(setOf("topic", "description"), data.path("backgrounds").path(0).fieldNames().asSequence().toSet())
|
||||
assertEquals(setOf("title", "content", "emotion"), data.path("memories").path(0).fieldNames().asSequence().toSet())
|
||||
assertEquals(setOf("id", "imageUrl", "title"), data.path("originalWork").fieldNames().asSequence().toSet())
|
||||
assertEquals(originalWork.id, data.path("originalWork").path("id").asLong())
|
||||
assertTrue(data.path("imageUrl").asText().endsWith("characters/${character.id}/detail.png"))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
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.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
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.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
|
||||
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 javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminOriginalWorkSearchTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("원작 검색은 제목·콘텐츠 타입·카테고리 부분 검색과 레거시 13개 필드 직접 배열을 반환한다")
|
||||
fun shouldSearchOriginalWorksByLegacyFieldsAndReturnDirectArray() {
|
||||
val titleMatch = saveOriginalWork("p2-r9 moon title", "webtoon", "romance")
|
||||
val typeMatch = saveOriginalWork("another title", "p2-r9 moon audio", "fantasy")
|
||||
val categoryMatch = saveOriginalWork("third title", "novel", "p2-r9 moon category")
|
||||
saveOriginalWork("deleted p2-r9 moon title", "webtoon", "romance", isDeleted = true)
|
||||
saveOriginalWork("not matched", "novel", "drama")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val response = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.param("searchTerm", "p2-r9 moon")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.length()").value(3))
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
val ids = data.map { it.path("id").asLong() }.toSet()
|
||||
|
||||
assertEquals(setOf(titleMatch.id, typeMatch.id, categoryMatch.id), ids)
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"title",
|
||||
"contentType",
|
||||
"category",
|
||||
"isAdult",
|
||||
"description",
|
||||
"originalWork",
|
||||
"originalLink",
|
||||
"writer",
|
||||
"studio",
|
||||
"originalLinks",
|
||||
"tags",
|
||||
"imageUrl"
|
||||
),
|
||||
data.first().fieldNames().asSequence().toSet()
|
||||
)
|
||||
val titleItem = data.first { it.path("id").asLong() == titleMatch.id }
|
||||
assertEquals(listOf("https://example.com/p2-r9-moon-title"), titleItem.path("originalLinks").map { it.asText() })
|
||||
assertEquals(listOf("p2-r9-moon-title-tag"), titleItem.path("tags").map { it.asText() })
|
||||
assertEquals("original p2-r9 moon title", titleItem.path("originalWork").asText())
|
||||
assertEquals("https://source.example.com/p2-r9 moon title", titleItem.path("originalLink").asText())
|
||||
assertEquals("writer p2-r9 moon title", titleItem.path("writer").asText())
|
||||
assertEquals("studio p2-r9 moon title", titleItem.path("studio").asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 검색은 결과가 없으면 빈 직접 배열을 반환한다")
|
||||
fun shouldReturnEmptyArrayWhenNoOriginalWorkMatches() {
|
||||
saveOriginalWork("unrelated original", "novel", "drama")
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.param("searchTerm", "missing-p2-r9")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.length()").value(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 검색은 필수 searchTerm 누락을 신규 prefix 오류 envelope로 반환한다")
|
||||
fun shouldRejectMissingSearchTermWithLocalizedError() {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 검색은 characterId 상세 route와 충돌하지 않고 ADMIN 이중 인가와 CORS를 상속한다")
|
||||
fun shouldKeepRouteAuthorizationAndCorsContract() {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.param("searchTerm", "p2-r9")
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
.with(nonAdminAuthentication())
|
||||
)
|
||||
.andExpect(status().isForbidden)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("You do not have permission."))
|
||||
|
||||
mockMvc.perform(
|
||||
options("/api/v2/admin/ai-characters/original-works/search")
|
||||
.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))
|
||||
}
|
||||
|
||||
private fun saveOriginalWork(
|
||||
title: String,
|
||||
contentType: String,
|
||||
category: String,
|
||||
isDeleted: Boolean = false
|
||||
): OriginalWork {
|
||||
val slug = title.replace(" ", "-")
|
||||
val originalWork = OriginalWork(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
isAdult = true,
|
||||
description = "description $title",
|
||||
originalWork = "original $title",
|
||||
originalLink = "https://source.example.com/$title",
|
||||
writer = "writer $title",
|
||||
studio = "studio $title"
|
||||
)
|
||||
originalWork.imagePath = "originals/$slug.png"
|
||||
originalWork.isDeleted = isDeleted
|
||||
originalWork.originalLinks.add(OriginalWorkLink("https://example.com/$slug", originalWork))
|
||||
val tag = OriginalWorkTag("$slug-tag")
|
||||
entityManager.persist(tag)
|
||||
originalWork.tagMappings.add(OriginalWorkTagMapping(originalWork, tag))
|
||||
entityManager.persist(originalWork)
|
||||
return originalWork
|
||||
}
|
||||
|
||||
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 companion object {
|
||||
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user