diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index bafe0430..9a0a78cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.chat.character.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -53,6 +56,48 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): Page + @EntityGraph(attributePaths = ["creatorMember", "originalWork"]) + @Query( + value = """ + SELECT DISTINCT c FROM ChatCharacter c + JOIN c.creatorMember cm + LEFT JOIN c.tagMappings tm + LEFT JOIN tm.tag t + WHERE c.isActive = true + AND cm.role = :role + AND cm.memberKind = :memberKind + AND ( + :searchTerm = '' OR + LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR + (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) + ) + """, + countQuery = """ + SELECT COUNT(DISTINCT c) FROM ChatCharacter c + JOIN c.creatorMember cm + LEFT JOIN c.tagMappings tm + LEFT JOIN tm.tag t + WHERE c.isActive = true + AND cm.role = :role + AND cm.memberKind = :memberKind + AND ( + :searchTerm = '' OR + LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR + (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) + ) + """ + ) + fun searchAiCharacters( + @Param("searchTerm") searchTerm: String, + @Param("role") role: MemberRole, + @Param("memberKind") memberKind: MemberKind, + pageable: Pageable + ): Page + /** * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) */ 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 new file mode 100644 index 00000000..0105c3e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +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 + +@RestController +@RequestMapping("/api/v2/admin/ai-characters") +class AiCharacterAdminCharacterController( + private val facade: AiCharacterAdminCharacterFacade +) { + @GetMapping + fun list( + @RequestParam(required = false) search: String?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(search, page, size)) + } + + @GetMapping("/{characterId:[0-9]+}") + fun detail(@PathVariable characterId: Long): ApiResponse { + return ApiResponse.ok(facade.detail(characterId)) + } + + @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)) + } + + @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)) + } +} 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 new file mode 100644 index 00000000..68465089 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt @@ -0,0 +1,116 @@ +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 +) + +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() +) + +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, + val speechPattern: String? = null, + val speechStyle: String? = null, + val appearance: String? = null, + val originalTitle: String? = null, + val originalLink: String? = null, + val originalWorkId: Long? = null, + val characterType: String? = null, + val isActive: Boolean? = null, + val tags: List? = null, + 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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt new file mode 100644 index 00000000..b97fb542 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate + +@Component +class AiCharacterAdminCharacterExternalApiClient( + @Value("\${weraser.api-key}") private val apiKey: String, + @Value("\${weraser.api-url}") private val apiUrl: String +) { + fun create(request: AiCharacterAdminCharacterCreateRequest): String { + val body = mutableMapOf( + "name" to request.name, + "systemPrompt" to request.systemPrompt, + "description" to request.description, + "region" to request.region + ) + request.age?.let { body["age"] = it } + request.gender?.let { body["gender"] = mapGender(request.region, it) } + request.mbti?.let { body["mbti"] = it } + request.speechPattern?.let { body["speechPattern"] = it } + request.speechStyle?.let { body["speechStyle"] = it } + request.appearance?.let { body["appearance"] = it } + if (request.tags.isNotEmpty()) body["tags"] = request.tags + if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies + if (request.values.isNotEmpty()) body["values"] = request.values + if (request.goals.isNotEmpty()) body["goals"] = request.goals + if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships + if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities + if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds + if (request.memories.isNotEmpty()) body["memories"] = request.memories + + return exchange(HttpMethod.POST, "/api/characters", body) ?: throw invalidRequest() + } + + fun update( + chatCharacter: ChatCharacter, + request: AiCharacterAdminCharacterUpdateRequest, + requestName: String? + ) { + val body = mutableMapOf() + if (request.isActive == false) { + val inactiveName = java.util.UUID.randomUUID().toString().replace("-", "") + body["name"] = "inactive_${requestName}_$inactiveName" + } else { + request.name?.let { body["name"] = it } + request.systemPrompt?.let { body["systemPrompt"] = it } + request.description?.let { body["description"] = it } + request.age?.let { body["age"] = it } + request.gender?.let { body["gender"] = it } + request.mbti?.let { body["mbti"] = it } + request.speechPattern?.let { body["speechPattern"] = it } + request.speechStyle?.let { body["speechStyle"] = it } + request.appearance?.let { body["appearance"] = it } + request.tags?.let { body["tags"] = it } + request.hobbies?.let { body["hobbies"] = it } + request.values?.let { body["values"] = it } + request.goals?.let { body["goals"] = it } + request.relationships?.let { body["relationships"] = it } + request.personalities?.let { body["personalities"] = it } + request.backgrounds?.let { body["backgrounds"] = it } + request.memories?.let { body["memories"] = it } + } + + exchange(HttpMethod.PUT, "/api/characters/${chatCharacter.characterUUID}", body) + } + + private fun exchange(method: HttpMethod, path: String, body: Map): String? { + val headers = HttpHeaders().apply { + set("x-api-key", apiKey) + contentType = MediaType.APPLICATION_JSON + } + val response = try { + createRestTemplate().exchange( + "$apiUrl$path", + method, + HttpEntity(body, headers), + ExternalCharacterResponse::class.java + ) + } catch (_: RestClientException) { + throw invalidRequest() + } + + val externalResponse = response.body ?: throw invalidRequest() + if (!externalResponse.success) { + throw invalidRequest() + } + return externalResponse.data?.id + } + + private fun createRestTemplate(): RestTemplate { + return RestTemplate( + SimpleClientHttpRequestFactory().apply { + setConnectTimeout(EXTERNAL_API_TIMEOUT_MILLIS) + setReadTimeout(EXTERNAL_API_TIMEOUT_MILLIS) + } + ) + } + + private fun mapGender(region: String, gender: String): String { + if (!region.equals("JP", ignoreCase = true)) return gender + return when (gender) { + "여성" -> "女性" + "남성" -> "男性" + "기타" -> "その他" + else -> gender + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class ExternalCharacterResponse( + val success: Boolean, + val data: ExternalCharacterData? = null + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class ExternalCharacterData( + val id: String + ) + + companion object { + private const val EXTERNAL_API_TIMEOUT_MILLIS = 20_000 + } +} 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 new file mode 100644 index 00000000..e98bb3a0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt @@ -0,0 +1,188 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +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 +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.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.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +@Service +class AiCharacterAdminCharacterFacade( + private val objectMapper: ObjectMapper, + private val chatCharacterService: ChatCharacterService, + private val chatCharacterRepository: ChatCharacterRepository, + private val targetResolver: AiCharacterAdminTargetResolver, + private val externalApiClient: AiCharacterAdminCharacterExternalApiClient, + private val imageStorage: AiCharacterAdminCharacterImageStorage, + private val characterMapper: AiCharacterAdminCharacterMapper, + private val originalWorkService: AdminOriginalWorkService, + private val creatorMemberService: ChatCharacterCreatorMemberService, + private val applicationEventPublisher: ApplicationEventPublisher +) { + @Transactional(readOnly = true) + fun list(search: String?, page: Int, size: Int): AiCharacterAdminListResponse { + val normalizedPage = page.coerceAtLeast(0) + val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE) + val characters = chatCharacterRepository.searchAiCharacters( + searchTerm = search?.trim().orEmpty(), + role = MemberRole.CREATOR, + memberKind = MemberKind.AI_CHARACTER, + pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt")) + ) + + return AiCharacterAdminListResponse( + totalCount = characters.totalElements, + items = characters.content.map(characterMapper::toListItemResponse), + page = normalizedPage, + size = normalizedSize, + hasNext = characters.hasNext() + ) + } + + @Transactional(readOnly = true) + fun detail(characterId: Long): AiCharacterAdminCharacterResponse { + return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter) + } + + @Transactional + fun create(image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse { + 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() + } + request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork) + + val chatCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = externalApiClient.create(request), + name = request.name, + description = request.description, + systemPrompt = request.systemPrompt, + age = request.age?.toIntOrNull(), + gender = request.gender, + mbti = request.mbti, + speechPattern = request.speechPattern, + speechStyle = request.speechStyle, + appearance = request.appearance, + originalTitle = request.originalTitle, + originalLink = request.originalLink, + characterType = characterType, + region = request.region, + tags = request.tags, + values = request.values, + hobbies = request.hobbies, + goals = request.goals, + 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) + ) + + if (image?.isEmpty == false) { + chatCharacter.imagePath = imageStorage.upload(chatCharacter.id ?: throw invalidRequest(), image) + chatCharacterService.saveChatCharacter(chatCharacter) + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter) + } + + request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, chatCharacter.id ?: throw invalidRequest()) } + if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = chatCharacter.id ?: throw invalidRequest(), + query = chatCharacter.description, + targetType = LanguageDetectTargetType.CHARACTER + ) + ) + } + + return characterMapper.toResponse(chatCharacter) + } + + @Transactional + fun update(characterId: Long, image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse { + val target = targetResolver.resolve(characterId) + val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java) + if (request.externalCharacterId != null) { + throw invalidRequest() + } + characterMapper.validateCharacterType(request) + if ( + request.isActive == false && + (characterMapper.hasNonSoftDeleteChanges(request) || image?.isEmpty == false) + ) { + throw invalidRequest() + } + if ( + !characterMapper.hasExternalChanges(request) && + !characterMapper.hasDbOnlyChanges(request) && + image?.isEmpty != false + ) { + throw invalidRequest() + } + if ( + request.name != null && request.name != target.chatCharacter.name && + chatCharacterService.findByName(request.name) != null + ) { + throw invalidRequest() + } + request.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 imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null + val updatedCharacter = chatCharacterService.updateChatCharacterWithDetails( + imagePath = imagePath, + request = characterMapper.toLegacyRequest(request, characterId, requestName) + ) + request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) } + chatCharacterRepository.flush() + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = characterId, + targetType = LanguageTranslationTargetType.CHARACTER, + waitTransactionCommit = true + ) + ) + + return characterMapper.toResponse(updatedCharacter) + } + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readValue(requestString, requestClass) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + companion object { + private const val MINIMUM_PAGE_SIZE = 20 + private const val MAXIMUM_PAGE_SIZE = 50 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt new file mode 100644 index 00000000..508aa4cb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.amazonaws.services.s3.model.ObjectMetadata +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile + +@Component +class AiCharacterAdminCharacterImageStorage( + private val s3Uploader: S3Uploader, + @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String +) { + fun upload(characterId: Long, image: MultipartFile): String { + val metadata = ObjectMetadata().apply { contentLength = image.size } + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = "characters/$characterId/${generateFileName(prefix = "character")}", + metadata = metadata + ) + } +} 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 new file mode 100644 index 00000000..6019bb0a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt @@ -0,0 +1,134 @@ +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.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 +import org.springframework.stereotype.Component + +@Component +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() + ) + } + + 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() + ) + } + + fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType { + return request.characterType?.let { toCharacterType(it) } ?: CharacterType.Character + } + + fun validateCharacterType(request: AiCharacterAdminCharacterUpdateRequest) { + request.characterType?.let { toCharacterType(it) } + } + + fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return hasRegularExternalChanges(request) || request.isActive == false + } + + fun hasNonSoftDeleteChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return hasRegularExternalChanges(request) || hasDbOnlyChanges(request) + } + + private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return request.name != null || request.systemPrompt != null || request.description != null || request.age != null || + request.gender != null || request.mbti != null || request.speechPattern != null || request.speechStyle != null || + request.appearance != null || request.tags != null || request.hobbies != null || + request.values != null || request.goals != null || request.relationships != null || request.personalities != null || + request.backgrounds != null || request.memories != null + } + + fun hasDbOnlyChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return request.originalTitle != null || request.originalLink != null || request.characterType != null || + request.originalWorkId != null + } + + fun toLegacyRequest( + request: AiCharacterAdminCharacterUpdateRequest, + characterId: Long, + requestName: String? + ): ChatCharacterUpdateRequest { + return ChatCharacterUpdateRequest( + id = characterId, + name = requestName, + systemPrompt = request.systemPrompt, + description = request.description, + age = request.age, + gender = request.gender, + mbti = request.mbti, + speechPattern = request.speechPattern, + speechStyle = request.speechStyle, + appearance = request.appearance, + originalTitle = request.originalTitle, + originalLink = request.originalLink, + originalWorkId = request.originalWorkId, + characterType = request.characterType, + isActive = request.isActive, + tags = request.tags, + 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 + ) + } + + private fun toCharacterType(characterType: String): CharacterType { + return runCatching { CharacterType.valueOf(characterType) }.getOrElse { throw invalidRequest() } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} 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 new file mode 100644 index 00000000..71b963c4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt @@ -0,0 +1,1114 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import com.fasterxml.jackson.databind.ObjectMapper +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +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.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +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.mockito.ArgumentCaptor +import org.mockito.Mockito +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.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +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.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +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.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicInteger +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val chatCharacterRepository: ChatCharacterRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, + private val objectMapper: ObjectMapper, + private val characterFacade: AiCharacterAdminCharacterFacade, + private val externalApiClient: AiCharacterAdminCharacterExternalApiClient, + private val characterMapper: AiCharacterAdminCharacterMapper, + private val originalWorkService: AdminOriginalWorkService, + private val creatorMemberService: ChatCharacterCreatorMemberService +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @SpyBean + private lateinit var chatCharacterRepositorySpy: ChatCharacterRepository + + @BeforeEach + fun resetCreateFlowObservers() { + externalRequestCount.set(0) + Mockito.reset(amazonS3Client) + Mockito.reset(applicationEventPublisher) + } + + @Test + @DisplayName("생성은 원작과 AI creatorMember 표시 정보를 만든다") + fun shouldCreateCharacterAndReturnDetailWithAiCreatorMember() { + val originalWork = OriginalWork( + title = "v2-original-work", + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "description" + ) + entityManager.persist(originalWork) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + """ + { + "name":"v2-created-character", + "systemPrompt":"prompt", + "description":"created description", + "region":"KR", + "originalWorkId":${originalWork.id}, + "characterType":"Character" + } + """.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)) + + entityManager.flush() + entityManager.clear() + val saved = chatCharacterRepository.findByName("v2-created-character")!! + val creatorMember = saved.creatorMember!! + + assertEquals(MemberRole.CREATOR, creatorMember.role) + assertEquals(MemberKind.AI_CHARACTER, creatorMember.memberKind) + assertEquals("v2-created-character", creatorMember.nickname) + assertEquals("created description", creatorMember.introduce) + assertEquals(originalWork.id, saved.originalWork!!.id) + } + + @Test + @DisplayName("생성 direct facade 호출은 언어 감지 event payload만 확인한다") + fun shouldPublishLanguageDetectEventOnCreate() { + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + + val response = createFacadeForEventPayloadAssertion(publisher).create( + image = null, + requestString = """ + { + "name":"v2-language-detect-event", + "systemPrompt":"prompt", + "description":"event description" + } + """.trimIndent() + ) + + 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("event description", event.query) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 읽을 수 없는 request JSON을 요청 언어의 invalid request로 거부한다") + fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + "{".toByteArray(StandardCharsets.UTF_8) + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 읽을 수 없는 request JSON을 요청 언어의 invalid request로 거부한다") + fun shouldReturnLocalizedErrorForUnreadableUpdateRequest(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-unreadable-update-request", + name = "v2-unreadable-update-request", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + "{".toByteArray(StandardCharsets.UTF_8) + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @Test + @DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다") + fun shouldRejectServerOwnedCreateFields() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"v2-server-owned-fields", + "systemPrompt":"prompt", + "description":"description", + "externalCharacterId":"client-id", + "isActive":false + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-server-owned-fields")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 invalid characterType을 외부 API 호출 전에 거부한다") + fun shouldRejectInvalidCharacterTypeBeforeExternalApi() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"v2-invalid-character-type", + "systemPrompt":"prompt", + "description":"description", + "characterType":"INVALID" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-invalid-character-type")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 중복 이름을 외부 API 호출 전에 거부한다") + fun shouldRejectDuplicateNameBeforeExternalApi() { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "existing-character", + name = "v2-duplicate-character", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"v2-duplicate-character", + "systemPrompt":"prompt", + "description":"description" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("existing-character", chatCharacterRepository.findByName("v2-duplicate-character")!!.characterUUID) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 존재하지 않는 원작을 외부 API 호출 전에 거부한다") + fun shouldRejectMissingOriginalWorkBeforeExternalApi() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"v2-missing-original-work", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":999999 + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-missing-original-work")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 외부 API 실패 시 DB, creatorMember와 이벤트를 남기지 않는다") + fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"v2-external-api-failure-$language", + "systemPrompt":"prompt", + "description":"description" + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-external-api-failure-$language")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 S3 실패 시 DB와 이벤트를 남기지 않고 외부 생성 호출만 남긴다") + fun shouldRollBackDatabaseStateWhenImageStorageFails(language: String, message: String) { + Mockito.doThrow(IllegalStateException("S3 failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))) + .file( + requestPart( + """ + { + "name":"v2-image-storage-failure-$language", + "systemPrompt":"prompt", + "description":"description" + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-image-storage-failure-$language")) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다") + fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) { + val characterName = "v2-create-event-failure-$language" + val originalWork = createOriginalWork("v2-create-event-original-work-$language") + Mockito.doThrow(IllegalStateException("event failure")) + .`when`(applicationEventPublisher) + .publishEvent(Mockito.any(Any::class.java)) + + val originalPublisher = replaceCharacterFacadePublisher(applicationEventPublisher) + try { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"$characterName", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":${originalWork.id} + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } finally { + replaceCharacterFacadePublisher(originalPublisher) + } + + assertEquals(1, externalRequestCount.get()) + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) + Mockito.verifyNoInteractions(amazonS3Client) + assertNoCreatedCharacterState(characterName, originalWork.id!!) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성 저장 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다") + fun shouldRollBackCreateStateWhenEndpointSaveFails(language: String, message: String) { + val characterName = "v2-create-save-failure-$language" + val originalWork = createOriginalWork("v2-create-save-original-work-$language") + Mockito.doThrow(IllegalStateException("save failure")) + .`when`(chatCharacterRepositorySpy) + .save(Mockito.any(ChatCharacter::class.java)) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"$characterName", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":${originalWork.id} + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + Mockito.verifyNoInteractions(applicationEventPublisher) + Mockito.verifyNoInteractions(amazonS3Client) + assertNoCreatedCharacterState(characterName, originalWork.id!!) + } + + @Test + @DisplayName("수정은 서버 소유 externalCharacterId를 일반 변경과 함께 받지 않는다") + fun shouldRejectServerOwnedExternalCharacterIdBeforeUpdateSideEffects() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-external-id", + name = "v2-update-external-id", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + requestPart( + """ + { + "externalCharacterId":"client-controlled-id", + "description":"after description" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정의 isActive false는 다른 변경이나 이미지를 함께 받지 않는다") + fun shouldRejectMixedSoftDeleteBeforeExternalAndImageSideEffects() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-mixed-soft-delete", + name = "v2-mixed-soft-delete", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + 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() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertTrue(chatCharacterRepository.findById(character.id!!).orElseThrow().isActive) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 존재하지 않는 원작을 외부 API 호출 전에 거부한다") + fun shouldRejectMissingOriginalWorkBeforeExternalUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-missing-original-work", + name = "v2-update-missing-original-work", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + requestPart( + """ + { + "name":"v2-update-missing-original-work-updated", + "originalWorkId":999999 + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("v2-update-missing-original-work", chatCharacterRepository.findById(character.id!!).orElseThrow().name) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 invalid characterType을 외부 API 호출 전에 거부한다") + fun shouldRejectInvalidCharacterTypeBeforeExternalUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-invalid-character-type", + name = "v2-update-invalid-character-type", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(requestPart("""{"characterType":"INVALID"}""")) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("v2-update-invalid-character-type", chatCharacterRepository.findById(character.id!!).orElseThrow().name) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 이미지가 없으면 기존 이미지를 유지한다") + fun shouldRetainExistingImageWhenUpdateHasNoImage() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-retain-image", + name = "v2-update-retain-image", + description = "before description", + systemPrompt = "prompt" + ) + character.imagePath = "characters/${character.id}/existing.png" + chatCharacterService.saveChatCharacter(character) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(requestPart("""{"description":"after description"}""")) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + entityManager.clear() + assertEquals( + "characters/${character.id}/existing.png", + chatCharacterRepository.findById(character.id!!).orElseThrow().imagePath + ) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 이미지와 표시 정보를 동기화하고 flush된 updatedAtUtc를 반환한다") + fun shouldUpdateCharacterImageAndCreatorDisplayFieldsWithFlushedTimestamp() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-character-update", + name = "v2-character-update", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + 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( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))) + .file( + requestPart( + """ + { + "name":"v2-character-updated", + "description":"after description" + } + """.trimIndent() + ) + ) + .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() + + entityManager.clear() + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow() + + assertEquals(detailTimestamp, updateTimestamp) + assertEquals(saved.imagePath, creatorMember.profileImage) + assertEquals(saved.name, creatorMember.nickname) + assertEquals(saved.description, creatorMember.introduce) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("수정의 이미지 저장 실패는 DB와 번역 이벤트를 남기지 않고 외부 수정만 남긴다") + fun shouldLeaveOnlyExternalUpdateWhenImageStorageFails(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-image-storage-failure-$language", + name = "v2-update-image-storage-failure-$language", + description = "before description", + systemPrompt = "prompt" + ) + Mockito.doThrow(IllegalStateException("S3 failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + externalRequestCount.set(0) + + 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("""{"description":"after description"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + assertEquals( + "before description", + chatCharacterRepository.findById(character.id!!).orElseThrow().description + ) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("수정 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 수정만 남긴다") + fun shouldRollBackUpdateStateWhenEndpointEventPublishFails(language: String, message: String) { + val fixture = createUpdateFailureFixture("v2-update-event-failure-$language") + Mockito.doThrow(IllegalStateException("event failure")) + .`when`(applicationEventPublisher) + .publishEvent(Mockito.any(Any::class.java)) + externalRequestCount.set(0) + + val originalPublisher = replaceCharacterFacadePublisher(applicationEventPublisher) + try { + performUpdateFailureRequest(fixture, language) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } finally { + replaceCharacterFacadePublisher(originalPublisher) + } + + assertEquals(1, externalRequestCount.get()) + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) + Mockito.verifyNoInteractions(amazonS3Client) + assertUnchangedUpdateState(fixture) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("수정 flush 실패는 endpoint transaction을 롤백하고 외부 수정만 남긴다") + fun shouldRollBackUpdateStateWhenEndpointFlushFails(language: String, message: String) { + val fixture = createUpdateFailureFixture("v2-update-flush-failure-$language") + Mockito.doThrow(IllegalStateException("flush failure")) + .`when`(chatCharacterRepositorySpy) + .flush() + externalRequestCount.set(0) + + performUpdateFailureRequest(fixture, language) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + Mockito.verifyNoInteractions(applicationEventPublisher) + Mockito.verifyNoInteractions(amazonS3Client) + assertUnchangedUpdateState(fixture) + } + + @Test + @DisplayName("수정 direct facade 호출은 번역 event payload만 확인한다") + fun shouldPublishTranslationEventOnUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-translation-event", + name = "v2-update-translation-event", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val targetResolver = Mockito.mock(AiCharacterAdminTargetResolver::class.java) + Mockito.`when`(targetResolver.resolve(character.id!!)).thenReturn( + AiCharacterAdminTarget(character.id!!, character, character.creatorMember!!) + ) + + createFacadeForEventPayloadAssertion(publisher, targetResolver).update( + character.id!!, + null, + """{"description":"after description"}""" + ) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(publisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent + assertEquals(character.id, event.id) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("수정의 isActive false는 AI creatorMember와 콘텐츠를 유지하며 soft delete 한다") + fun shouldSoftDeleteCharacterAndRetainCreatorMember() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-soft-delete-character", + name = "v2-soft-delete-character", + description = "description", + systemPrompt = "prompt" + ) + val creatorMemberId = character.creatorMember!!.id!! + val audioContent = saveAudioContent(character.creatorMember!!) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + """{"isActive":false}""".toByteArray(StandardCharsets.UTF_8) + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.isActive").value(false)) + + entityManager.flush() + entityManager.clear() + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertFalse(saved.isActive) + assertEquals(creatorMemberId, saved.creatorMember!!.id) + assertTrue(memberRepository.existsById(creatorMemberId)) + assertTrue(entityManager.find(AudioContent::class.java, audioContent.id!!) != null) + assertEquals(creatorMemberId, entityManager.find(AudioContent::class.java, audioContent.id!!).member!!.id) + } + + 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 requestPart(request: String): MockMultipartFile { + return MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + request.trimIndent().toByteArray(StandardCharsets.UTF_8) + ) + } + + private fun saveAudioContent(creator: Member): AudioContent { + val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true) + entityManager.persist(theme) + val content = AudioContent( + title = "update-audio-${creator.id}", + detail = "detail", + languageCode = "ko" + ) + content.member = creator + content.theme = theme + content.isActive = true + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun assertNoCreatedCharacterState(characterName: String, originalWorkId: Long) { + val state = transactionTemplate.execute { + entityManager.clear() + CreateRollbackState( + characterExists = chatCharacterRepository.findByName(characterName) != null, + creatorMemberExists = memberRepository.findByNickname(characterName) != null, + originalWorkCharacterCount = entityManager.createQuery( + "select count(c) from ChatCharacter c where c.originalWork.id = :originalWorkId" + ) + .setParameter("originalWorkId", originalWorkId) + .singleResult as Long + ) + }!! + + assertFalse(state.characterExists) + assertFalse(state.creatorMemberExists) + assertEquals(0L, state.originalWorkCharacterCount) + } + + private fun createOriginalWork(title: String): OriginalWork { + return transactionTemplate.execute { + OriginalWork( + title = title, + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "description" + ).also { + entityManager.persist(it) + entityManager.flush() + } + }!! + } + + private fun createUpdateFailureFixture(name: String): UpdateFailureFixture { + val originalWork = createOriginalWork("$name-original-work") + val updatedOriginalWork = createOriginalWork("$name-updated-original-work") + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "before description", + systemPrompt = "prompt" + ) + originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!) + + return UpdateFailureFixture( + characterId = character.id!!, + name = name, + description = "before description", + creatorNickname = name, + creatorIntroduce = "before description", + originalWorkId = originalWork.id!!, + updatedName = "$name-updated", + updatedDescription = "after description", + updatedOriginalWorkId = updatedOriginalWork.id!! + ) + } + + private fun performUpdateFailureRequest(fixture: UpdateFailureFixture, language: String): ResultActions { + return mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${fixture.characterId}") + .file( + requestPart( + """ + { + "name":"${fixture.updatedName}", + "description":"${fixture.updatedDescription}", + "originalWorkId":${fixture.updatedOriginalWorkId} + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } + + private fun assertUnchangedUpdateState(fixture: UpdateFailureFixture) { + val state = transactionTemplate.execute { + entityManager.clear() + val saved = chatCharacterRepository.findById(fixture.characterId).orElseThrow() + UpdateRollbackState( + name = saved.name, + description = saved.description, + creatorNickname = saved.creatorMember!!.nickname, + creatorIntroduce = saved.creatorMember!!.introduce, + originalWorkId = saved.originalWork!!.id!! + ) + }!! + + assertEquals(fixture.name, state.name) + assertEquals(fixture.description, state.description) + assertEquals(fixture.creatorNickname, state.creatorNickname) + assertEquals(fixture.creatorIntroduce, state.creatorIntroduce) + assertEquals(fixture.originalWorkId, state.originalWorkId) + } + + private fun replaceCharacterFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(characterFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun createFacadeForEventPayloadAssertion( + publisher: ApplicationEventPublisher, + targetResolver: AiCharacterAdminTargetResolver = Mockito.mock(AiCharacterAdminTargetResolver::class.java) + ): AiCharacterAdminCharacterFacade { + return AiCharacterAdminCharacterFacade( + objectMapper = objectMapper, + chatCharacterService = chatCharacterService, + chatCharacterRepository = chatCharacterRepository, + targetResolver = targetResolver, + externalApiClient = externalApiClient, + imageStorage = Mockito.mock(AiCharacterAdminCharacterImageStorage::class.java), + characterMapper = characterMapper, + originalWorkService = originalWorkService, + creatorMemberService = creatorMemberService, + applicationEventPublisher = publisher + ) + } + + private data class CreateRollbackState( + val characterExists: Boolean, + val creatorMemberExists: Boolean, + val originalWorkCharacterCount: Long + ) + + private data class UpdateFailureFixture( + val characterId: Long, + val name: String, + val description: String, + val creatorNickname: String, + val creatorIntroduce: String, + val originalWorkId: Long, + val updatedName: String, + val updatedDescription: String, + val updatedOriginalWorkId: Long + ) + + private data class UpdateRollbackState( + val name: String, + val description: String, + val creatorNickname: String, + val creatorIntroduce: String, + val originalWorkId: Long + ) + + companion object { + private val externalRequestCount = AtomicInteger() + private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/characters") { exchange -> + externalRequestCount.incrementAndGet() + val requestBody = exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8) + val response = if (requestBody.contains("v2-external-api-failure")) { + """{"success":false}""" + } else { + """{"success":true,"data":{"id":"external-v2-created-character"}}""" + } + .toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE) + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) { + registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopExternalCharacterApi() { + externalCharacterApi.stop(0) + } + } +} 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 new file mode 100644 index 00000000..2245cd78 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt @@ -0,0 +1,345 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.endsWith +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCharacterControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @Test + @DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다") + fun shouldNormalizeListPageAndMinimumSize() { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-list-character", + name = "v2-list-character", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("page", "-1") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].name").value("v2-list-character")) + .andExpect(jsonPath("$.data.items[0].creatorProfileImageUrl").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].creatorIntroduce").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].updatedAtUtc").doesNotExist()) + } + + @Test + @DisplayName("검색은 최대 size를 50으로 보정한다") + fun shouldNormalizeSearchMaximumSize() { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-search-character", + name = "v2-search-character", + description = "find this character", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("search", "find this") + .param("size", "100") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.size").value(50)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].externalCharacterId").value("v2-search-character")) + } + + @Test + @DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다") + fun shouldReturnDetailForAiCharacter() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-detail-character", + name = "v2-detail-character", + description = "detail description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.characterId").value(character.id)) + .andExpect(jsonPath("$.data.externalCharacterId").value("v2-detail-character")) + .andExpect(jsonPath("$.data.creatorMemberId").value(character.creatorMember!!.id)) + .andExpect(jsonPath("$.data.creatorNickname").value("v2-detail-character")) + .andExpect(jsonPath("$.data.creatorProfileImageUrl").value(endsWith("/profile/default-profile.png"))) + .andExpect(jsonPath("$.data.creatorIntroduce").value("detail description")) + .andExpect(jsonPath("$.data.isActive").value(true)) + .andExpect(jsonPath("$.data.createdAtUtc").value(endsWith("Z"))) + .andExpect(jsonPath("$.data.updatedAtUtc").value(endsWith("Z"))) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("상세는 AI creatorMember가 아닌 target을 요청 언어의 invalid request로 거부한다") + fun shouldReturnLocalizedErrorForInvalidDetailTarget(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-invalid-target", + name = "v2-invalid-target", + description = "description", + systemPrompt = "prompt" + ) + character.creatorMember!!.memberKind = MemberKind.HUMAN + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("목록 binding 오류는 요청 언어의 invalid request를 반환한다") + fun shouldReturnLocalizedErrorForInvalidListBinding(language: String, message: String) { + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("page", "not-a-number") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @Test + @DisplayName("목록·상세·생성·수정 endpoint는 non-ADMIN JWT를 binding 전에 거부한다") + fun shouldRejectNonAdminJwtRoleForEveryCharacterEndpoint() { + actualCharacterEndpointRequests().forEach { request -> + mockMvc.perform( + request + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(nonAdminAuthentication()) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("You do not have permission.")) + } + } + + @Test + @DisplayName("목록·상세·생성·수정 endpoint는 stale ADMIN claim을 binding 전에 거부한다") + fun shouldRejectStaleAdminClaimForEveryCharacterEndpoint() { + actualCharacterEndpointRequests().forEach { request -> + mockMvc.perform( + request + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(staleAdminAuthentication()) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("You do not have permission.")) + } + } + + @Test + @DisplayName("실제 상세 endpoint preflight는 캐릭터 관리자 Origin을 허용한다") + fun shouldApplyCorsToDetailEndpointPreflight() { + mockMvc.perform( + options("/api/v2/admin/ai-characters/1") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @ParameterizedTest + @CsvSource( + "/api/v2/admin/ai-characters,GET", + "/api/v2/admin/ai-characters/1,GET", + "/api/v2/admin/ai-characters,POST", + "/api/v2/admin/ai-characters/1,PUT" + ) + @DisplayName("실제 character endpoint preflight는 캐릭터 관리자 Origin만 허용한다") + fun shouldApplyCorsPreflightToEveryCharacterEndpoint(path: String, method: String) { + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("목록 endpoint는 ADMIN 인증을 상속한다") + fun shouldInheritAdminAuthorizationForListEndpoint() { + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun nonAdminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "user@example.com", + password = "password", + nickname = "user", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_USER")) + ) + ) + + private fun staleAdminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "stale-admin@example.com", + password = "password", + nickname = "stale-admin", + role = MemberRole.USER + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun actualCharacterEndpointRequests(): List { + return listOf( + get("/api/v2/admin/ai-characters"), + get("/api/v2/admin/ai-characters/1"), + post("/api/v2/admin/ai-characters"), + put("/api/v2/admin/ai-characters/1") + ) + } + + companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + private const val CREATOR_ORIGIN = "https://creator.sodalive.net" + + private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/characters") { exchange -> + val response = """{"success":true,"data":{"id":"external-v2-created-character"}}""" + .toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE) + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) { + registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopExternalCharacterApi() { + externalCharacterApi.stop(0) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt new file mode 100644 index 00000000..1281c151 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt @@ -0,0 +1,164 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.amazonaws.services.s3.AmazonS3Client +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterController +import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.net.InetSocketAddress +import java.net.URL +import java.nio.charset.StandardCharsets +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest @Autowired constructor( + private val chatCharacterService: ChatCharacterService, + private val adminChatCharacterService: AdminChatCharacterService, + private val originalWorkService: AdminOriginalWorkService, + private val creatorMemberService: ChatCharacterCreatorMemberService, + private val entityManager: EntityManager +) { + @Test + fun `기존 관리자 캐릭터 등록은 설명 언어 감지 이벤트를 발행한다`() { + val server = startExternalCharacterApi() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val controller = createLegacyController( + s3Uploader = stubS3Uploader("characters/event/profile.png"), + publisher = publisher, + apiUrl = "http://localhost:${server.address.port}" + ) + + try { + controller.registerCharacter( + image = MockMultipartFile("image", "image.png", "image/png", byteArrayOf(1)), + requestString = """ + { + "name":"legacy-character-language-detect", + "systemPrompt":"prompt", + "description":"detect this description", + "age":"21", + "gender":"여성", + "region":"KR", + "tags":[], + "hobbies":[], + "values":[], + "goals":[], + "relationships":[], + "personalities":[], + "backgrounds":[], + "memories":[] + } + """.trimIndent() + ) + entityManager.flush() + + val saved = chatCharacterService.findByName("legacy-character-language-detect")!! + val event = capturePublishedEvent(publisher) as LanguageDetectEvent + + assertEquals(saved.id, event.id) + assertEquals("detect this description", event.query) + assertEquals(LanguageDetectTargetType.CHARACTER, event.targetType) + } finally { + server.stop(0) + } + } + + @Test + fun `기존 관리자 캐릭터 수정은 번역 이벤트를 발행한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-translation", + name = "legacy-character-translation", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val controller = createLegacyController( + s3Uploader = stubS3Uploader("unused.png"), + publisher = publisher, + apiUrl = "http://localhost" + ) + + controller.updateCharacter( + image = null, + requestString = """ + { + "id":${character.id}, + "originalTitle":"changed original" + } + """.trimIndent() + ) + + val event = capturePublishedEvent(publisher) as LanguageTranslationEvent + + assertEquals(character.id, event.id) + assertEquals(LanguageTranslationTargetType.CHARACTER, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + private fun createLegacyController( + s3Uploader: S3Uploader, + publisher: ApplicationEventPublisher, + apiUrl: String + ): AdminChatCharacterController { + return AdminChatCharacterController( + service = chatCharacterService, + adminService = adminChatCharacterService, + s3Uploader = s3Uploader, + originalWorkService = originalWorkService, + creatorMemberService = creatorMemberService, + applicationEventPublisher = publisher, + apiKey = "test-api-key", + apiUrl = apiUrl, + s3Bucket = "test-bucket", + imageHost = "https://cdn.example.com" + ) + } + + private fun stubS3Uploader(path: String): S3Uploader { + val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://cdn.example.com/$path")) + return S3Uploader(amazonS3Client) + } + + private fun startExternalCharacterApi(): HttpServer { + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/api/characters") { exchange -> + val response = """{"success":true,"data":{"id":"external-event-character"}}""" + .toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + server.start() + return server + } + + private fun capturePublishedEvent(publisher: ApplicationEventPublisher): Any { + val captor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(publisher).publishEvent(captor.capture()) + return captor.value + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt new file mode 100644 index 00000000..43045953 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt @@ -0,0 +1,220 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyChatCharacterAdminCharacterizationTest @Autowired constructor( + private val chatCharacterService: ChatCharacterService, + private val originalWorkService: AdminOriginalWorkService, + private val chatCharacterRepository: ChatCharacterRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager +) { + @Test + fun `기존 관리자 캐릭터 생성은 AI creatorMember와 상세 정보를 함께 저장한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-1", + name = "legacy-character-create", + description = "legacy description", + systemPrompt = "system prompt", + age = 21, + gender = "여성", + mbti = "INFP", + speechPattern = "polite", + speechStyle = "soft", + appearance = "silver hair", + originalTitle = "original title", + originalLink = "https://example.com/original", + characterType = CharacterType.Character, + region = "KR", + tags = listOf("tag-a", "tag-a", "tag-b"), + values = listOf("value-a"), + hobbies = listOf("hobby-a"), + goals = listOf("goal-a"), + memories = listOf(Triple("memory", "content", "happy")), + personalities = listOf(Pair("kind", "kind desc")) + ) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + val creatorMember = saved.creatorMember + + assertNotNull(creatorMember) + assertEquals(MemberRole.CREATOR, creatorMember!!.role) + assertEquals(MemberKind.AI_CHARACTER, creatorMember.memberKind) + assertEquals("legacy-character-create", creatorMember.nickname) + assertEquals("legacy description", creatorMember.introduce) + assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted()) + assertEquals(listOf("value-a"), saved.valueMappings.map { it.value.value }) + assertEquals(listOf("hobby-a"), saved.hobbyMappings.map { it.hobby.hobby }) + assertEquals(listOf("goal-a"), saved.goalMappings.map { it.goal.goal }) + assertEquals(listOf("memory"), saved.memories.map { it.title }) + assertEquals(listOf("kind"), saved.personalities.map { it.trait }) + } + + @Test + fun `기존 관리자 원작 배정은 캐릭터 originalWork 연결을 저장한다`() { + val originalWork = saveOriginalWork("legacy-original-work") + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-original", + name = "legacy-character-original-work", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertEquals(originalWork.id, saved.originalWork!!.id) + assertEquals("legacy-original-work", saved.originalWork!!.title) + } + + @Test + fun `기존 관리자 캐릭터 수정은 변경 필드와 AI creatorMember 표시 정보를 동기화한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-2", + name = "legacy-character-update", + description = "before description", + systemPrompt = "before prompt", + tags = listOf("old-tag"), + memories = listOf(Triple("old-memory", "old content", "sad")) + ) + entityManager.flush() + + chatCharacterService.updateChatCharacterWithDetails( + imagePath = "characters/${character.id}/profile.png", + request = ChatCharacterUpdateRequest( + id = character.id!!, + name = "legacy-character-updated", + description = "after description", + age = "24", + tags = listOf("new-tag"), + memories = listOf(ChatCharacterMemoryRequest("new-memory", "new content", "calm")), + personalities = listOf(ChatCharacterPersonalityRequest("calm", "calm desc")) + ) + ) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow() + + assertEquals("legacy-character-updated", saved.name) + assertEquals("after description", saved.description) + assertEquals(24, saved.age) + assertEquals("characters/${character.id}/profile.png", saved.imagePath) + assertEquals(listOf("new-tag"), saved.tagMappings.map { it.tag.tag }) + assertEquals(listOf("new-memory"), saved.memories.map { it.title }) + assertEquals(listOf("calm"), saved.personalities.map { it.trait }) + assertEquals("legacy-character-updated", creatorMember.nickname) + assertEquals("characters/${character.id}/profile.png", creatorMember.profileImage) + assertEquals("after description", creatorMember.introduce) + } + + @Test + fun `기존 관리자 캐릭터 비활성화는 row와 creatorMember를 남기고 isActive false로 저장한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-3", + name = "legacy-character-disable", + description = "disable description", + systemPrompt = "prompt" + ) + val creatorMemberId = character.creatorMember!!.id!! + val audioContent = saveAudioContent(character.creatorMember!!) + entityManager.flush() + + chatCharacterService.updateChatCharacterWithDetails( + request = ChatCharacterUpdateRequest( + id = character.id!!, + name = character.name, + isActive = false + ) + ) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertFalse(saved.isActive) + assertTrue(saved.name.startsWith("inactive_legacy-character-disable_")) + assertTrue(memberRepository.existsById(creatorMemberId)) + assertEquals(creatorMemberId, saved.creatorMember!!.id) + assertNotNull(entityManager.find(AudioContent::class.java, audioContent.id!!)) + assertEquals(creatorMemberId, entityManager.find(AudioContent::class.java, audioContent.id!!).member!!.id) + } + + @Test + fun `기존 관리자 중복 이름 검증은 findByName 조회 결과에 의존한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-4", + name = "legacy-duplicate-name", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + entityManager.clear() + + val found = chatCharacterService.findByName("legacy-duplicate-name") + + assertEquals(character.id, found!!.id) + } + + private fun saveOriginalWork(title: String): OriginalWork { + val originalWork = OriginalWork( + title = title, + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "description" + ) + entityManager.persist(originalWork) + return originalWork + } + + private fun saveAudioContent(creator: Member): AudioContent { + val theme = AudioContentTheme(theme = "legacy-theme-${creator.id}", image = "theme.png", isActive = true) + entityManager.persist(theme) + val content = AudioContent( + title = "legacy-audio-${creator.id}", + detail = "detail", + languageCode = "ko" + ) + content.member = creator + content.theme = theme + content.isActive = true + content.duration = "00:10:00" + entityManager.persist(content) + return content + } +}