From 4479c192c9e51e629a38a8e823e0588c32485636 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 30 Jul 2026 00:30:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiCharacterAdminCharacterController.kt | 58 +- .../character/AiCharacterAdminCharacterDto.kt | 108 +-- .../AiCharacterAdminCharacterFacade.kt | 81 ++- .../AiCharacterAdminCharacterMapper.kt | 63 +- ...terAdminCharacterControllerMutationTest.kt | 667 ++++++++++++++++-- ...AiCharacterAdminCharacterControllerTest.kt | 199 +++++- .../AiCharacterAdminOriginalWorkSearchTest.kt | 205 ++++++ 7 files changed, 1085 insertions(+), 296 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt index 0105c3e4..68e3dae5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt @@ -1,7 +1,11 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.web.HttpMediaTypeNotSupportedException import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -11,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest @RestController @RequestMapping("/api/v2/admin/ai-characters") @@ -19,11 +24,18 @@ class AiCharacterAdminCharacterController( ) { @GetMapping fun list( - @RequestParam(required = false) search: String?, + @RequestParam(required = false) searchTerm: String?, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int ): ApiResponse { - return ApiResponse.ok(facade.list(search, page, size)) + return ApiResponse.ok(facade.list(searchTerm, page, size)) + } + + @GetMapping("/original-works/search") + fun searchOriginalWorks( + @RequestParam("searchTerm") searchTerm: String + ): ApiResponse> { + return ApiResponse.ok(facade.searchOriginalWorks(searchTerm)) } @GetMapping("/{characterId:[0-9]+}") @@ -33,18 +45,46 @@ class AiCharacterAdminCharacterController( @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun create( - @RequestPart(value = "image", required = false) image: MultipartFile?, - @RequestPart("request") request: String - ): ApiResponse { - return ApiResponse.ok(facade.create(image, request)) + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.create(image, request) + return ApiResponse.ok(null) } @PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun update( @PathVariable characterId: Long, @RequestPart(value = "image", required = false) image: MultipartFile?, - @RequestPart("request") request: String - ): ApiResponse { - return ApiResponse.ok(facade.update(characterId, image, request)) + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, image, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) { + if (multipartRequest.fileMap.keys.any { it !in CHARACTER_MULTIPART_PARTS } || + multipartRequest.parts.any { it.name !in CHARACTER_MULTIPART_PARTS } + ) { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + } + + private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) { + val contentType = multipartRequest.getMultipartHeaders("request")?.contentType + ?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType) + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON)) + } + } + + companion object { + private val CHARACTER_MULTIPART_PARTS = setOf("image", "request") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt index 68465089..d012c263 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt @@ -1,74 +1,23 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character -data class AiCharacterAdminListResponse( - val totalCount: Long, - val items: List, - val page: Int, - val size: Int, - val hasNext: Boolean -) +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse +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.ChatCharacterRegisterRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest -data class AiCharacterAdminCharacterListItemResponse( - val characterId: Long, - val name: String, - val description: String, - val imageUrl: String, - val creatorMemberId: Long, - val creatorNickname: String, - val originalWorkId: Long?, - val externalCharacterId: String, - val isActive: Boolean, - val createdAtUtc: String? -) - -data class AiCharacterAdminCharacterResponse( - val characterId: Long, - val name: String, - val description: String, - val imageUrl: String, - val creatorMemberId: Long, - val creatorNickname: String, - val creatorProfileImageUrl: String, - val creatorIntroduce: String?, - val originalWorkId: Long?, - val externalCharacterId: String, - val isActive: Boolean, - val createdAtUtc: String?, - val updatedAtUtc: String? -) - -data class AiCharacterAdminCharacterCreateRequest( - val name: String, - val systemPrompt: String, - val description: String, - val externalCharacterId: String? = null, - val isActive: Boolean? = null, - val age: String? = null, - val gender: String? = null, - val mbti: String? = null, - val speechPattern: String? = null, - val speechStyle: String? = null, - val appearance: String? = null, - val region: String = "KR", - val originalTitle: String? = null, - val originalLink: String? = null, - val originalWorkId: Long? = null, - val characterType: String? = null, - val tags: List = emptyList(), - val hobbies: List = emptyList(), - val values: List = emptyList(), - val goals: List = emptyList(), - val relationships: List = emptyList(), - val personalities: List = emptyList(), - val backgrounds: List = emptyList(), - val memories: List = emptyList() -) +typealias AiCharacterAdminListResponse = ChatCharacterListPageResponse +typealias AiCharacterAdminCharacterListItemResponse = ChatCharacterListResponse +typealias AiCharacterAdminCharacterResponse = ChatCharacterDetailResponse +typealias AiCharacterAdminCharacterCreateRequest = ChatCharacterRegisterRequest data class AiCharacterAdminCharacterUpdateRequest( val name: String? = null, val systemPrompt: String? = null, val description: String? = null, - val externalCharacterId: String? = null, val age: String? = null, val gender: String? = null, val mbti: String? = null, @@ -84,33 +33,8 @@ data class AiCharacterAdminCharacterUpdateRequest( val hobbies: List? = null, val values: List? = null, val goals: List? = null, - val relationships: List? = null, - val personalities: List? = null, - val backgrounds: List? = null, - val memories: List? = null -) - -data class AiCharacterAdminCharacterRelationshipRequest( - val personName: String, - val relationshipName: String, - val description: String, - val importance: Int, - val relationshipType: String, - val currentStatus: String -) - -data class AiCharacterAdminCharacterPersonalityRequest( - val trait: String, - val description: String -) - -data class AiCharacterAdminCharacterBackgroundRequest( - val topic: String, - val description: String -) - -data class AiCharacterAdminCharacterMemoryRequest( - val title: String, - val content: String, - val emotion: String + val relationships: List? = null, + val personalities: List? = null, + val backgrounds: List? = null, + val memories: List? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt index e98bb3a0..25ecdff4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt @@ -1,7 +1,9 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService @@ -14,6 +16,7 @@ import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort @@ -33,14 +36,16 @@ class AiCharacterAdminCharacterFacade( private val characterMapper: AiCharacterAdminCharacterMapper, private val originalWorkService: AdminOriginalWorkService, private val creatorMemberService: ChatCharacterCreatorMemberService, - private val applicationEventPublisher: ApplicationEventPublisher + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String ) { @Transactional(readOnly = true) - fun list(search: String?, page: Int, size: Int): AiCharacterAdminListResponse { + fun list(searchTerm: String?, page: Int, size: Int): AiCharacterAdminListResponse { val normalizedPage = page.coerceAtLeast(0) - val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE) + val normalizedSize = size.coerceAtLeast(MINIMUM_PAGE_SIZE) val characters = chatCharacterRepository.searchAiCharacters( - searchTerm = search?.trim().orEmpty(), + searchTerm = searchTerm?.trim().orEmpty(), role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt")) @@ -48,10 +53,7 @@ class AiCharacterAdminCharacterFacade( return AiCharacterAdminListResponse( totalCount = characters.totalElements, - items = characters.content.map(characterMapper::toListItemResponse), - page = normalizedPage, - size = normalizedSize, - hasNext = characters.hasNext() + content = characters.content.map(characterMapper::toListItemResponse) ) } @@ -60,12 +62,16 @@ class AiCharacterAdminCharacterFacade( return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter) } + @Transactional(readOnly = true) + fun searchOriginalWorks(searchTerm: String): List { + return originalWorkService.searchOriginalWorksAll(searchTerm) + .map { OriginalWorkResponse.from(it, imageHost) } + } + @Transactional - fun create(image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse { + fun create(image: MultipartFile?, requestString: String) { + if (image?.isEmpty == true) throw invalidRequest() val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java) - if (request.externalCharacterId != null || request.isActive != null) { - throw invalidRequest() - } val characterType = characterMapper.toCharacterType(request) if (chatCharacterService.findByName(request.name) != null) { throw invalidRequest() @@ -94,7 +100,7 @@ class AiCharacterAdminCharacterFacade( memories = request.memories.map { Triple(it.title, it.content, it.emotion) }, personalities = request.personalities.map { Pair(it.trait, it.description) }, backgrounds = request.backgrounds.map { Pair(it.topic, it.description) }, - relationships = request.relationships.map(characterMapper::toLegacyRequest) + relationships = request.relationships ) if (image?.isEmpty == false) { @@ -113,50 +119,47 @@ class AiCharacterAdminCharacterFacade( ) ) } - - return characterMapper.toResponse(chatCharacter) } @Transactional - fun update(characterId: Long, image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse { + fun update(characterId: Long, image: MultipartFile?, requestString: String) { val target = targetResolver.resolve(characterId) val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java) - if (request.externalCharacterId != null) { + if (request.isActive == false && image?.isEmpty == false) { throw invalidRequest() } - characterMapper.validateCharacterType(request) - if ( - request.isActive == false && - (characterMapper.hasNonSoftDeleteChanges(request) || image?.isEmpty == false) - ) { - throw invalidRequest() + val effectiveRequest = if (request.isActive == false) { + AiCharacterAdminCharacterUpdateRequest(isActive = false) + } else { + request } + characterMapper.validateCharacterType(effectiveRequest) if ( - !characterMapper.hasExternalChanges(request) && - !characterMapper.hasDbOnlyChanges(request) && + !characterMapper.hasExternalChanges(effectiveRequest) && + !characterMapper.hasDbOnlyChanges(effectiveRequest) && image?.isEmpty != false ) { throw invalidRequest() } if ( - request.name != null && request.name != target.chatCharacter.name && - chatCharacterService.findByName(request.name) != null + effectiveRequest.name != null && effectiveRequest.name != target.chatCharacter.name && + chatCharacterService.findByName(effectiveRequest.name) != null ) { throw invalidRequest() } - request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork) + effectiveRequest.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork) - val requestName = if (request.isActive == false) request.name ?: target.chatCharacter.name else request.name - if (characterMapper.hasExternalChanges(request)) { - externalApiClient.update(target.chatCharacter, request, requestName) + val requestName = if (effectiveRequest.isActive == false) target.chatCharacter.name else effectiveRequest.name + if (characterMapper.hasExternalChanges(effectiveRequest)) { + externalApiClient.update(target.chatCharacter, effectiveRequest, requestName) } val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null - val updatedCharacter = chatCharacterService.updateChatCharacterWithDetails( + chatCharacterService.updateChatCharacterWithDetails( imagePath = imagePath, - request = characterMapper.toLegacyRequest(request, characterId, requestName) + request = characterMapper.toLegacyRequest(effectiveRequest, characterId, requestName) ) - request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) } + effectiveRequest.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) } chatCharacterRepository.flush() applicationEventPublisher.publishEvent( LanguageTranslationEvent( @@ -165,13 +168,14 @@ class AiCharacterAdminCharacterFacade( waitTransactionCommit = true ) ) - - return characterMapper.toResponse(updatedCharacter) } private fun readRequest(requestString: String, requestClass: Class): T { return try { - objectMapper.readValue(requestString, requestClass) + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .readValue(requestString) } catch (_: JsonProcessingException) { throw invalidRequest() } @@ -182,7 +186,6 @@ class AiCharacterAdminCharacterFacade( } companion object { - private const val MINIMUM_PAGE_SIZE = 20 - private const val MAXIMUM_PAGE_SIZE = 50 + private const val MINIMUM_PAGE_SIZE = 1 } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt index 6019bb0a..06f07de4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt @@ -1,13 +1,10 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest -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.ChatCharacterRelationshipRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpStatus @@ -18,38 +15,11 @@ class AiCharacterAdminCharacterMapper( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse { - val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest() - return AiCharacterAdminCharacterListItemResponse( - characterId = chatCharacter.id ?: throw invalidRequest(), - name = chatCharacter.name, - description = chatCharacter.description, - imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}", - creatorMemberId = creatorMember.id ?: throw invalidRequest(), - creatorNickname = creatorMember.nickname, - originalWorkId = chatCharacter.originalWork?.id, - externalCharacterId = chatCharacter.characterUUID, - isActive = chatCharacter.isActive, - createdAtUtc = chatCharacter.createdAt?.toUtcIso() - ) + return ChatCharacterListResponse.from(chatCharacter, imageHost) } fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse { - val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest() - return AiCharacterAdminCharacterResponse( - characterId = chatCharacter.id ?: throw invalidRequest(), - name = chatCharacter.name, - description = chatCharacter.description, - imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}", - creatorMemberId = creatorMember.id ?: throw invalidRequest(), - creatorNickname = creatorMember.nickname, - creatorProfileImageUrl = "$imageHost/${creatorMember.profileImage ?: "profile/default-profile.png"}", - creatorIntroduce = creatorMember.introduce, - originalWorkId = chatCharacter.originalWork?.id, - externalCharacterId = chatCharacter.characterUUID, - isActive = chatCharacter.isActive, - createdAtUtc = chatCharacter.createdAt?.toUtcIso(), - updatedAtUtc = chatCharacter.updatedAt?.toUtcIso() - ) + return ChatCharacterDetailResponse.from(chatCharacter, imageHost) } fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType { @@ -61,11 +31,7 @@ class AiCharacterAdminCharacterMapper( } fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { - return hasRegularExternalChanges(request) || request.isActive == false - } - - fun hasNonSoftDeleteChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { - return hasRegularExternalChanges(request) || hasDbOnlyChanges(request) + return hasRegularExternalChanges(request) || request.isActive != null } private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { @@ -106,21 +72,10 @@ class AiCharacterAdminCharacterMapper( hobbies = request.hobbies, values = request.values, goals = request.goals, - relationships = request.relationships?.map(::toLegacyRequest), - personalities = request.personalities?.map { ChatCharacterPersonalityRequest(it.trait, it.description) }, - backgrounds = request.backgrounds?.map { ChatCharacterBackgroundRequest(it.topic, it.description) }, - memories = request.memories?.map { ChatCharacterMemoryRequest(it.title, it.content, it.emotion) } - ) - } - - fun toLegacyRequest(request: AiCharacterAdminCharacterRelationshipRequest): ChatCharacterRelationshipRequest { - return ChatCharacterRelationshipRequest( - personName = request.personName, - relationshipName = request.relationshipName, - description = request.description, - importance = request.importance, - relationshipType = request.relationshipType, - currentStatus = request.currentStatus + relationships = request.relationships, + personalities = request.personalities, + backgrounds = request.backgrounds, + memories = request.memories ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt index 71b963c4..f9d7bdcf 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt @@ -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,,ko,잘못된 요청입니다.", + "POST,,en,Invalid request.", + "POST,,ja,無効なリクエストです。", + "PUT,text/plain,ko,잘못된 요청입니다.", + "PUT,text/plain,en,Invalid request.", + "PUT,text/plain,ja,無効なリクエストです。", + "PUT,,ko,잘못된 요청입니다.", + "PUT,,en,Invalid request.", + "PUT,,ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @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 -> diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt index 2245cd78..674e5396 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt @@ -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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt new file mode 100644 index 00000000..0e7e0199 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt @@ -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" + } +}