feat(ai-character): 캐릭터 관리자 레거시 계약을 보강한다
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -11,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/admin/ai-characters")
|
||||
@@ -19,11 +24,18 @@ class AiCharacterAdminCharacterController(
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(
|
||||
@RequestParam(required = false) search: String?,
|
||||
@RequestParam(required = false) searchTerm: String?,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminListResponse> {
|
||||
return ApiResponse.ok(facade.list(search, page, size))
|
||||
return ApiResponse.ok(facade.list(searchTerm, page, size))
|
||||
}
|
||||
|
||||
@GetMapping("/original-works/search")
|
||||
fun searchOriginalWorks(
|
||||
@RequestParam("searchTerm") searchTerm: String
|
||||
): ApiResponse<List<OriginalWorkResponse>> {
|
||||
return ApiResponse.ok(facade.searchOriginalWorks(searchTerm))
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId:[0-9]+}")
|
||||
@@ -33,18 +45,46 @@ class AiCharacterAdminCharacterController(
|
||||
|
||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun create(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") request: String
|
||||
): ApiResponse<AiCharacterAdminCharacterResponse> {
|
||||
return ApiResponse.ok(facade.create(image, request))
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") request: String,
|
||||
multipartRequest: MultipartHttpServletRequest
|
||||
): ApiResponse<Nothing> {
|
||||
requireAllowedMultipartParts(multipartRequest)
|
||||
requireJsonRequestPart(multipartRequest)
|
||||
facade.create(image, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun update(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") request: String
|
||||
): ApiResponse<AiCharacterAdminCharacterResponse> {
|
||||
return ApiResponse.ok(facade.update(characterId, image, request))
|
||||
@RequestPart("request") request: String,
|
||||
multipartRequest: MultipartHttpServletRequest
|
||||
): ApiResponse<Nothing> {
|
||||
requireAllowedMultipartParts(multipartRequest)
|
||||
requireJsonRequestPart(multipartRequest)
|
||||
facade.update(characterId, image, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) {
|
||||
if (multipartRequest.fileMap.keys.any { it !in CHARACTER_MULTIPART_PARTS } ||
|
||||
multipartRequest.parts.any { it.name !in CHARACTER_MULTIPART_PARTS }
|
||||
) {
|
||||
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
|
||||
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
|
||||
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
|
||||
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val CHARACTER_MULTIPART_PARTS = setOf("image", "request")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
data class AiCharacterAdminListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<AiCharacterAdminCharacterListItemResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||
|
||||
data class AiCharacterAdminCharacterListItemResponse(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: String,
|
||||
val creatorMemberId: Long,
|
||||
val creatorNickname: String,
|
||||
val originalWorkId: Long?,
|
||||
val externalCharacterId: String,
|
||||
val isActive: Boolean,
|
||||
val createdAtUtc: String?
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCharacterResponse(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: String,
|
||||
val creatorMemberId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorIntroduce: String?,
|
||||
val originalWorkId: Long?,
|
||||
val externalCharacterId: String,
|
||||
val isActive: Boolean,
|
||||
val createdAtUtc: String?,
|
||||
val updatedAtUtc: String?
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCharacterCreateRequest(
|
||||
val name: String,
|
||||
val systemPrompt: String,
|
||||
val description: String,
|
||||
val externalCharacterId: String? = null,
|
||||
val isActive: Boolean? = null,
|
||||
val age: String? = null,
|
||||
val gender: String? = null,
|
||||
val mbti: String? = null,
|
||||
val speechPattern: String? = null,
|
||||
val speechStyle: String? = null,
|
||||
val appearance: String? = null,
|
||||
val region: String = "KR",
|
||||
val originalTitle: String? = null,
|
||||
val originalLink: String? = null,
|
||||
val originalWorkId: Long? = null,
|
||||
val characterType: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val hobbies: List<String> = emptyList(),
|
||||
val values: List<String> = emptyList(),
|
||||
val goals: List<String> = emptyList(),
|
||||
val relationships: List<AiCharacterAdminCharacterRelationshipRequest> = emptyList(),
|
||||
val personalities: List<AiCharacterAdminCharacterPersonalityRequest> = emptyList(),
|
||||
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest> = emptyList(),
|
||||
val memories: List<AiCharacterAdminCharacterMemoryRequest> = emptyList()
|
||||
)
|
||||
typealias AiCharacterAdminListResponse = ChatCharacterListPageResponse
|
||||
typealias AiCharacterAdminCharacterListItemResponse = ChatCharacterListResponse
|
||||
typealias AiCharacterAdminCharacterResponse = ChatCharacterDetailResponse
|
||||
typealias AiCharacterAdminCharacterCreateRequest = ChatCharacterRegisterRequest
|
||||
|
||||
data class AiCharacterAdminCharacterUpdateRequest(
|
||||
val name: String? = null,
|
||||
val systemPrompt: String? = null,
|
||||
val description: String? = null,
|
||||
val externalCharacterId: String? = null,
|
||||
val age: String? = null,
|
||||
val gender: String? = null,
|
||||
val mbti: String? = null,
|
||||
@@ -84,33 +33,8 @@ data class AiCharacterAdminCharacterUpdateRequest(
|
||||
val hobbies: List<String>? = null,
|
||||
val values: List<String>? = null,
|
||||
val goals: List<String>? = null,
|
||||
val relationships: List<AiCharacterAdminCharacterRelationshipRequest>? = null,
|
||||
val personalities: List<AiCharacterAdminCharacterPersonalityRequest>? = null,
|
||||
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest>? = null,
|
||||
val memories: List<AiCharacterAdminCharacterMemoryRequest>? = null
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCharacterRelationshipRequest(
|
||||
val personName: String,
|
||||
val relationshipName: String,
|
||||
val description: String,
|
||||
val importance: Int,
|
||||
val relationshipType: String,
|
||||
val currentStatus: String
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCharacterPersonalityRequest(
|
||||
val trait: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCharacterBackgroundRequest(
|
||||
val topic: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCharacterMemoryRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val emotion: String
|
||||
val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||
val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||
val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||
val memories: List<ChatCharacterMemoryRequest>? = null
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
|
||||
@@ -14,6 +16,7 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
@@ -33,14 +36,16 @@ class AiCharacterAdminCharacterFacade(
|
||||
private val characterMapper: AiCharacterAdminCharacterMapper,
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
private val creatorMemberService: ChatCharacterCreatorMemberService,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun list(search: String?, page: Int, size: Int): AiCharacterAdminListResponse {
|
||||
fun list(searchTerm: String?, page: Int, size: Int): AiCharacterAdminListResponse {
|
||||
val normalizedPage = page.coerceAtLeast(0)
|
||||
val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE)
|
||||
val normalizedSize = size.coerceAtLeast(MINIMUM_PAGE_SIZE)
|
||||
val characters = chatCharacterRepository.searchAiCharacters(
|
||||
searchTerm = search?.trim().orEmpty(),
|
||||
searchTerm = searchTerm?.trim().orEmpty(),
|
||||
role = MemberRole.CREATOR,
|
||||
memberKind = MemberKind.AI_CHARACTER,
|
||||
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
@@ -48,10 +53,7 @@ class AiCharacterAdminCharacterFacade(
|
||||
|
||||
return AiCharacterAdminListResponse(
|
||||
totalCount = characters.totalElements,
|
||||
items = characters.content.map(characterMapper::toListItemResponse),
|
||||
page = normalizedPage,
|
||||
size = normalizedSize,
|
||||
hasNext = characters.hasNext()
|
||||
content = characters.content.map(characterMapper::toListItemResponse)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,12 +62,16 @@ class AiCharacterAdminCharacterFacade(
|
||||
return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun searchOriginalWorks(searchTerm: String): List<OriginalWorkResponse> {
|
||||
return originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||
.map { OriginalWorkResponse.from(it, imageHost) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
|
||||
fun create(image: MultipartFile?, requestString: String) {
|
||||
if (image?.isEmpty == true) throw invalidRequest()
|
||||
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
|
||||
if (request.externalCharacterId != null || request.isActive != null) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
val characterType = characterMapper.toCharacterType(request)
|
||||
if (chatCharacterService.findByName(request.name) != null) {
|
||||
throw invalidRequest()
|
||||
@@ -94,7 +100,7 @@ class AiCharacterAdminCharacterFacade(
|
||||
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||
relationships = request.relationships.map(characterMapper::toLegacyRequest)
|
||||
relationships = request.relationships
|
||||
)
|
||||
|
||||
if (image?.isEmpty == false) {
|
||||
@@ -113,50 +119,47 @@ class AiCharacterAdminCharacterFacade(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return characterMapper.toResponse(chatCharacter)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(characterId: Long, image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
|
||||
fun update(characterId: Long, image: MultipartFile?, requestString: String) {
|
||||
val target = targetResolver.resolve(characterId)
|
||||
val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java)
|
||||
if (request.externalCharacterId != null) {
|
||||
if (request.isActive == false && image?.isEmpty == false) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
characterMapper.validateCharacterType(request)
|
||||
if (
|
||||
request.isActive == false &&
|
||||
(characterMapper.hasNonSoftDeleteChanges(request) || image?.isEmpty == false)
|
||||
) {
|
||||
throw invalidRequest()
|
||||
val effectiveRequest = if (request.isActive == false) {
|
||||
AiCharacterAdminCharacterUpdateRequest(isActive = false)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
characterMapper.validateCharacterType(effectiveRequest)
|
||||
if (
|
||||
!characterMapper.hasExternalChanges(request) &&
|
||||
!characterMapper.hasDbOnlyChanges(request) &&
|
||||
!characterMapper.hasExternalChanges(effectiveRequest) &&
|
||||
!characterMapper.hasDbOnlyChanges(effectiveRequest) &&
|
||||
image?.isEmpty != false
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
if (
|
||||
request.name != null && request.name != target.chatCharacter.name &&
|
||||
chatCharacterService.findByName(request.name) != null
|
||||
effectiveRequest.name != null && effectiveRequest.name != target.chatCharacter.name &&
|
||||
chatCharacterService.findByName(effectiveRequest.name) != null
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
|
||||
effectiveRequest.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
|
||||
|
||||
val requestName = if (request.isActive == false) request.name ?: target.chatCharacter.name else request.name
|
||||
if (characterMapper.hasExternalChanges(request)) {
|
||||
externalApiClient.update(target.chatCharacter, request, requestName)
|
||||
val requestName = if (effectiveRequest.isActive == false) target.chatCharacter.name else effectiveRequest.name
|
||||
if (characterMapper.hasExternalChanges(effectiveRequest)) {
|
||||
externalApiClient.update(target.chatCharacter, effectiveRequest, requestName)
|
||||
}
|
||||
|
||||
val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null
|
||||
val updatedCharacter = chatCharacterService.updateChatCharacterWithDetails(
|
||||
chatCharacterService.updateChatCharacterWithDetails(
|
||||
imagePath = imagePath,
|
||||
request = characterMapper.toLegacyRequest(request, characterId, requestName)
|
||||
request = characterMapper.toLegacyRequest(effectiveRequest, characterId, requestName)
|
||||
)
|
||||
request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) }
|
||||
effectiveRequest.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) }
|
||||
chatCharacterRepository.flush()
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
@@ -165,13 +168,14 @@ class AiCharacterAdminCharacterFacade(
|
||||
waitTransactionCommit = true
|
||||
)
|
||||
)
|
||||
|
||||
return characterMapper.toResponse(updatedCharacter)
|
||||
}
|
||||
|
||||
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
|
||||
return try {
|
||||
objectMapper.readValue(requestString, requestClass)
|
||||
objectMapper.readerFor(requestClass)
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
|
||||
.readValue(requestString)
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
@@ -182,7 +186,6 @@ class AiCharacterAdminCharacterFacade(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MINIMUM_PAGE_SIZE = 20
|
||||
private const val MAXIMUM_PAGE_SIZE = 50
|
||||
private const val MINIMUM_PAGE_SIZE = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
@@ -18,38 +15,11 @@ class AiCharacterAdminCharacterMapper(
|
||||
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
|
||||
) {
|
||||
fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse {
|
||||
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
|
||||
return AiCharacterAdminCharacterListItemResponse(
|
||||
characterId = chatCharacter.id ?: throw invalidRequest(),
|
||||
name = chatCharacter.name,
|
||||
description = chatCharacter.description,
|
||||
imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}",
|
||||
creatorMemberId = creatorMember.id ?: throw invalidRequest(),
|
||||
creatorNickname = creatorMember.nickname,
|
||||
originalWorkId = chatCharacter.originalWork?.id,
|
||||
externalCharacterId = chatCharacter.characterUUID,
|
||||
isActive = chatCharacter.isActive,
|
||||
createdAtUtc = chatCharacter.createdAt?.toUtcIso()
|
||||
)
|
||||
return ChatCharacterListResponse.from(chatCharacter, imageHost)
|
||||
}
|
||||
|
||||
fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse {
|
||||
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
|
||||
return AiCharacterAdminCharacterResponse(
|
||||
characterId = chatCharacter.id ?: throw invalidRequest(),
|
||||
name = chatCharacter.name,
|
||||
description = chatCharacter.description,
|
||||
imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}",
|
||||
creatorMemberId = creatorMember.id ?: throw invalidRequest(),
|
||||
creatorNickname = creatorMember.nickname,
|
||||
creatorProfileImageUrl = "$imageHost/${creatorMember.profileImage ?: "profile/default-profile.png"}",
|
||||
creatorIntroduce = creatorMember.introduce,
|
||||
originalWorkId = chatCharacter.originalWork?.id,
|
||||
externalCharacterId = chatCharacter.characterUUID,
|
||||
isActive = chatCharacter.isActive,
|
||||
createdAtUtc = chatCharacter.createdAt?.toUtcIso(),
|
||||
updatedAtUtc = chatCharacter.updatedAt?.toUtcIso()
|
||||
)
|
||||
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||
}
|
||||
|
||||
fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType {
|
||||
@@ -61,11 +31,7 @@ class AiCharacterAdminCharacterMapper(
|
||||
}
|
||||
|
||||
fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
||||
return hasRegularExternalChanges(request) || request.isActive == false
|
||||
}
|
||||
|
||||
fun hasNonSoftDeleteChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
||||
return hasRegularExternalChanges(request) || hasDbOnlyChanges(request)
|
||||
return hasRegularExternalChanges(request) || request.isActive != null
|
||||
}
|
||||
|
||||
private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
||||
@@ -106,21 +72,10 @@ class AiCharacterAdminCharacterMapper(
|
||||
hobbies = request.hobbies,
|
||||
values = request.values,
|
||||
goals = request.goals,
|
||||
relationships = request.relationships?.map(::toLegacyRequest),
|
||||
personalities = request.personalities?.map { ChatCharacterPersonalityRequest(it.trait, it.description) },
|
||||
backgrounds = request.backgrounds?.map { ChatCharacterBackgroundRequest(it.topic, it.description) },
|
||||
memories = request.memories?.map { ChatCharacterMemoryRequest(it.title, it.content, it.emotion) }
|
||||
)
|
||||
}
|
||||
|
||||
fun toLegacyRequest(request: AiCharacterAdminCharacterRelationshipRequest): ChatCharacterRelationshipRequest {
|
||||
return ChatCharacterRelationshipRequest(
|
||||
personName = request.personName,
|
||||
relationshipName = request.relationshipName,
|
||||
description = request.description,
|
||||
importance = request.importance,
|
||||
relationshipType = request.relationshipType,
|
||||
currentStatus = request.currentStatus
|
||||
relationships = request.relationships,
|
||||
personalities = request.personalities,
|
||||
backgrounds = request.backgrounds,
|
||||
memories = request.memories
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.CsvSource
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -43,6 +44,7 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.mock.web.MockPart
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
@@ -55,6 +57,8 @@ import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.ResultActions
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
@@ -97,11 +101,13 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
externalRequestCount.set(0)
|
||||
Mockito.reset(amazonS3Client)
|
||||
Mockito.reset(applicationEventPublisher)
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(java.net.URL("https", "cdn.example.com", "/image.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 원작과 AI creatorMember 표시 정보를 만든다")
|
||||
fun shouldCreateCharacterAndReturnDetailWithAiCreatorMember() {
|
||||
@DisplayName("생성은 필수 image와 레거시 전체 request를 반영하고 data null을 반환한다")
|
||||
fun shouldCreateCharacterFromLegacyRequestAndReturnNullData() {
|
||||
val originalWork = OriginalWork(
|
||||
title = "v2-original-work",
|
||||
contentType = "webtoon",
|
||||
@@ -113,32 +119,48 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"v2-created-character",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"created description",
|
||||
"region":"KR",
|
||||
"age":"21",
|
||||
"gender":"여성",
|
||||
"mbti":"INFP",
|
||||
"speechPattern":"polite",
|
||||
"speechStyle":"soft",
|
||||
"appearance":"silver hair",
|
||||
"region":"JP",
|
||||
"originalTitle":"legacy title",
|
||||
"originalLink":"original-link",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"characterType":"Character"
|
||||
"characterType":"Clone",
|
||||
"tags":["tag-a","tag-b"],
|
||||
"hobbies":["reading"],
|
||||
"values":["honesty"],
|
||||
"goals":["friendship"],
|
||||
"relationships":[{
|
||||
"personName":"Mina",
|
||||
"relationshipName":"friend",
|
||||
"description":"best friend",
|
||||
"importance":10,
|
||||
"relationshipType":"ALLY",
|
||||
"currentStatus":"ACTIVE"
|
||||
}],
|
||||
"personalities":[{"trait":"kind","description":"kind description"}],
|
||||
"backgrounds":[{"topic":"hometown","description":"moon city"}],
|
||||
"memories":[{"title":"memory title","content":"memory content","emotion":"happy"}]
|
||||
}
|
||||
""".trimIndent().toByteArray(StandardCharsets.UTF_8)
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.name").value("v2-created-character"))
|
||||
.andExpect(jsonPath("$.data.externalCharacterId").value("external-v2-created-character"))
|
||||
.andExpect(jsonPath("$.data.creatorNickname").value("v2-created-character"))
|
||||
.andExpect(jsonPath("$.data.originalWorkId").value(originalWork.id))
|
||||
.andExpect(jsonPath("$.data.isActive").value(true))
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
@@ -150,6 +172,160 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
assertEquals("v2-created-character", creatorMember.nickname)
|
||||
assertEquals("created description", creatorMember.introduce)
|
||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
||||
assertEquals(21, saved.age)
|
||||
assertEquals("여성", saved.gender)
|
||||
assertEquals("INFP", saved.mbti)
|
||||
assertEquals("polite", saved.speechPattern)
|
||||
assertEquals("soft", saved.speechStyle)
|
||||
assertEquals("silver hair", saved.appearance)
|
||||
assertEquals("JP", saved.region)
|
||||
assertEquals("legacy title", saved.originalTitle)
|
||||
assertEquals("original-link", saved.originalLink)
|
||||
assertEquals("Clone", saved.characterType.name)
|
||||
assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted())
|
||||
assertEquals(listOf("reading"), saved.hobbyMappings.map { it.hobby.hobby })
|
||||
assertEquals(listOf("honesty"), saved.valueMappings.map { it.value.value })
|
||||
assertEquals(listOf("friendship"), saved.goalMappings.map { it.goal.goal })
|
||||
assertEquals(listOf("Mina"), saved.relationships.map { it.personName })
|
||||
assertEquals(listOf("kind"), saved.personalities.map { it.trait })
|
||||
assertEquals(listOf("hometown"), saved.backgrounds.map { it.topic })
|
||||
assertEquals(listOf("memory title"), saved.memories.map { it.title })
|
||||
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 image part를 필수로 요구한다")
|
||||
fun shouldRequireImageForCreate() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"v2-missing-required-image",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description"
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
assertNull(chatCharacterRepository.findByName("v2-missing-required-image"))
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 빈 필수 image를 외부 API 호출 전에 거부한다")
|
||||
fun shouldRejectEmptyCreateImageBeforeSideEffects() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(MockMultipartFile("image", "empty.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf()))
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"v2-empty-required-image",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description"
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
assertNull(chatCharacterRepository.findByName("v2-empty-required-image"))
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("생성은 관계 importance 누락을 외부 API 호출 전에 거부한다")
|
||||
fun shouldRejectMissingRelationshipImportanceBeforeSideEffects() {
|
||||
val characterName = "v2-missing-relationship-importance"
|
||||
val originalWork = createOriginalWork("$characterName-original-work")
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"$characterName",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"relationships":[{
|
||||
"personName":"Mina",
|
||||
"relationshipName":"friend",
|
||||
"description":"best friend",
|
||||
"relationshipType":"ALLY",
|
||||
"currentStatus":"ACTIVE"
|
||||
}]
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("생성은 관계 importance null을 외부 API 호출 전에 거부한다")
|
||||
fun shouldRejectNullRelationshipImportanceBeforeSideEffects() {
|
||||
val characterName = "v2-null-relationship-importance"
|
||||
val originalWork = createOriginalWork("$characterName-original-work")
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"name":"$characterName",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"relationships":[{
|
||||
"personName":"Mina",
|
||||
"relationshipName":"friend",
|
||||
"description":"best friend",
|
||||
"importance":null,
|
||||
"relationshipType":"ALLY",
|
||||
"currentStatus":"ACTIVE"
|
||||
}]
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,7 +333,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldPublishLanguageDetectEventOnCreate() {
|
||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
|
||||
val response = createFacadeForEventPayloadAssertion(publisher).create(
|
||||
createFacadeForEventPayloadAssertion(publisher).create(
|
||||
image = null,
|
||||
requestString = """
|
||||
{
|
||||
@@ -171,7 +347,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
||||
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
|
||||
val event = eventCaptor.value as LanguageDetectEvent
|
||||
assertEquals(response.characterId, event.id)
|
||||
assertEquals(chatCharacterRepository.findByName("v2-language-detect-event")!!.id, event.id)
|
||||
assertEquals("event description", event.query)
|
||||
}
|
||||
|
||||
@@ -185,11 +361,12 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
)
|
||||
@@ -223,7 +400,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"{".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
)
|
||||
@@ -235,11 +412,271 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"POST,ko,잘못된 요청입니다.",
|
||||
"POST,en,Invalid request.",
|
||||
"POST,ja,無効なリクエストです。",
|
||||
"PUT,ko,잘못된 요청입니다.",
|
||||
"PUT,en,Invalid request.",
|
||||
"PUT,ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("생성·수정은 미정의 multipart part를 지역화된 400과 부작용 없음으로 거부한다")
|
||||
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(method: String, language: String, message: String) {
|
||||
val characterName = "v2-undefined-part-$method-$language"
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = characterName,
|
||||
name = characterName,
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entityManager.flush()
|
||||
externalRequestCount.set(0)
|
||||
val request = """
|
||||
{
|
||||
"name":"$characterName",
|
||||
"description":"after description",
|
||||
"systemPrompt":"prompt"
|
||||
}
|
||||
"""
|
||||
val unexpectedPart = MockMultipartFile(
|
||||
"unexpected",
|
||||
"unexpected.txt",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
"unexpected".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(requestPart(request))
|
||||
.file(unexpectedPart)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.file(requestPart(request))
|
||||
.file(unexpectedPart)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
|
||||
entityManager.clear()
|
||||
if (character == null) {
|
||||
assertNull(chatCharacterRepository.findByName(characterName))
|
||||
} else {
|
||||
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
|
||||
}
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"POST,ko,잘못된 요청입니다.",
|
||||
"POST,en,Invalid request.",
|
||||
"POST,ja,無効なリクエストです。",
|
||||
"PUT,ko,잘못된 요청입니다.",
|
||||
"PUT,en,Invalid request.",
|
||||
"PUT,ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("생성·수정은 filename 없는 미정의 multipart part를 mutation 전 400으로 거부한다")
|
||||
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(
|
||||
method: String,
|
||||
language: String,
|
||||
message: String
|
||||
) {
|
||||
val characterName = "v2-filename-less-part-$method-$language"
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = characterName,
|
||||
name = characterName,
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
entityManager.flush()
|
||||
externalRequestCount.set(0)
|
||||
val request = """
|
||||
{
|
||||
"name":"$characterName",
|
||||
"description":"after description",
|
||||
"systemPrompt":"prompt"
|
||||
}
|
||||
"""
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(requestPart(request))
|
||||
.part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8)))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.file(requestPart(request))
|
||||
.part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8)))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
|
||||
entityManager.clear()
|
||||
if (character == null) {
|
||||
assertNull(chatCharacterRepository.findByName(characterName))
|
||||
} else {
|
||||
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
|
||||
}
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
value = [
|
||||
"POST,text/plain,ko,잘못된 요청입니다.",
|
||||
"POST,text/plain,en,Invalid request.",
|
||||
"POST,text/plain,ja,無効なリクエストです。",
|
||||
"POST,<missing>,ko,잘못된 요청입니다.",
|
||||
"POST,<missing>,en,Invalid request.",
|
||||
"POST,<missing>,ja,無効なリクエストです。",
|
||||
"PUT,text/plain,ko,잘못된 요청입니다.",
|
||||
"PUT,text/plain,en,Invalid request.",
|
||||
"PUT,text/plain,ja,無効なリクエストです。",
|
||||
"PUT,<missing>,ko,잘못된 요청입니다.",
|
||||
"PUT,<missing>,en,Invalid request.",
|
||||
"PUT,<missing>,ja,無効なリクエストです。"
|
||||
],
|
||||
nullValues = ["<missing>"]
|
||||
)
|
||||
@DisplayName("생성·수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
|
||||
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
|
||||
method: String,
|
||||
requestContentType: String?,
|
||||
language: String,
|
||||
message: String
|
||||
) {
|
||||
val characterName = "v2-non-json-$method-${requestContentType ?: "missing"}-$language"
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = characterName,
|
||||
name = characterName,
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val request = """
|
||||
{
|
||||
"name":"$characterName",
|
||||
"description":"after description",
|
||||
"systemPrompt":"prompt"
|
||||
}
|
||||
"""
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(requestPart(request, requestContentType))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.file(requestPart(request, requestContentType))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isUnsupportedMediaType)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||
|
||||
entityManager.clear()
|
||||
if (character == null) {
|
||||
assertNull(chatCharacterRepository.findByName(characterName))
|
||||
} else {
|
||||
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
|
||||
}
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["POST", "PUT"])
|
||||
@DisplayName("생성·수정은 request part 누락을 기존 400으로 유지한다")
|
||||
fun shouldKeepMissingRequestPartAsBadRequest(method: String) {
|
||||
val character = if (method == "PUT") {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-missing-request-part",
|
||||
name = "v2-missing-request-part",
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val result = if (method == "POST") {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
} else {
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
}
|
||||
|
||||
result
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다")
|
||||
fun shouldRejectServerOwnedCreateFields() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -268,6 +705,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldRejectInvalidCharacterTypeBeforeExternalApi() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -304,6 +742,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -330,6 +769,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldRejectMissingOriginalWorkBeforeExternalApi() {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -362,6 +802,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -430,7 +871,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다")
|
||||
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성과 S3 호출만 남긴다")
|
||||
fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) {
|
||||
val characterName = "v2-create-event-failure-$language"
|
||||
val originalWork = createOriginalWork("v2-create-event-original-work-$language")
|
||||
@@ -442,6 +883,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
try {
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -466,7 +908,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
assertEquals(1, externalRequestCount.get())
|
||||
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java))
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||
}
|
||||
|
||||
@@ -487,6 +929,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters")
|
||||
.file(createImagePart())
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
@@ -547,12 +990,64 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정의 isActive false는 다른 변경이나 이미지를 함께 받지 않는다")
|
||||
fun shouldRejectMixedSoftDeleteBeforeExternalAndImageSideEffects() {
|
||||
@DisplayName("수정의 isActive false는 다른 optional JSON 필드를 허용하고 비활성화만 반영한다")
|
||||
fun shouldIgnoreOtherFieldsInMixedSoftDeleteRequest() {
|
||||
val originalWork = createOriginalWork("v2-mixed-soft-delete-original")
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-mixed-soft-delete",
|
||||
name = "v2-mixed-soft-delete",
|
||||
description = "before description",
|
||||
systemPrompt = "before prompt",
|
||||
tags = listOf("before-tag")
|
||||
)
|
||||
originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!)
|
||||
entityManager.flush()
|
||||
externalRequestCount.set(0)
|
||||
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"isActive":false,
|
||||
"systemPrompt":"ignored prompt",
|
||||
"description":"ignored description",
|
||||
"age":"99",
|
||||
"characterType":"Clone",
|
||||
"originalWorkId":999999,
|
||||
"tags":["ignored-tag"]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||
|
||||
assertEquals(1, externalRequestCount.get())
|
||||
assertFalse(saved.isActive)
|
||||
assertEquals("before description", saved.description)
|
||||
assertEquals("before prompt", saved.systemPrompt)
|
||||
assertNull(saved.age)
|
||||
assertEquals("Character", saved.characterType.name)
|
||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
||||
assertEquals(listOf("before-tag"), saved.tagMappings.map { it.tag.tag })
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정의 isActive true 단독 요청은 레거시처럼 유효한 no-op mutation이다")
|
||||
fun shouldAcceptIsActiveTrueOnlyUpdateAsLegacyNoOp() {
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-is-active-true-only",
|
||||
name = "v2-is-active-true-only",
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
entityManager.flush()
|
||||
@@ -560,24 +1055,18 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
||||
.file(
|
||||
requestPart(
|
||||
"""
|
||||
{
|
||||
"isActive":false,
|
||||
"description":"ignored description"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.file(requestPart("""{"isActive":true}"""))
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
assertEquals(0, externalRequestCount.get())
|
||||
assertTrue(chatCharacterRepository.findById(character.id!!).orElseThrow().isActive)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||
assertTrue(saved.isActive)
|
||||
assertEquals("before description", saved.description)
|
||||
assertEquals(1, externalRequestCount.get())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@@ -659,6 +1148,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.clear()
|
||||
assertEquals(
|
||||
@@ -669,20 +1159,21 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정은 이미지와 표시 정보를 동기화하고 flush된 updatedAtUtc를 반환한다")
|
||||
fun shouldUpdateCharacterImageAndCreatorDisplayFieldsWithFlushedTimestamp() {
|
||||
@DisplayName("수정은 레거시 optional 필드를 반영하고 data null을 반환한다")
|
||||
fun shouldUpdateCharacterFromLegacyOptionalFieldsAndReturnNullData() {
|
||||
val originalWork = createOriginalWork("v2-update-original-work")
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-character-update",
|
||||
name = "v2-character-update",
|
||||
description = "before description",
|
||||
systemPrompt = "prompt"
|
||||
systemPrompt = "before prompt",
|
||||
tags = listOf("before-tag")
|
||||
)
|
||||
entityManager.flush()
|
||||
val beforeUpdatedAt = character.updatedAt!!
|
||||
Thread.sleep(5)
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(java.net.URL("https://cdn.example.com/characters/updated.png"))
|
||||
|
||||
val updateResponse = mockMvc.perform(
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
||||
.file(
|
||||
@@ -690,7 +1181,33 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
"""
|
||||
{
|
||||
"name":"v2-character-updated",
|
||||
"description":"after description"
|
||||
"systemPrompt":"after prompt",
|
||||
"description":"after description",
|
||||
"age":"29",
|
||||
"gender":"여성",
|
||||
"mbti":"ENTJ",
|
||||
"speechPattern":"direct",
|
||||
"speechStyle":"formal",
|
||||
"appearance":"black hair",
|
||||
"originalTitle":"updated title",
|
||||
"originalLink":"updated-link",
|
||||
"originalWorkId":${originalWork.id},
|
||||
"characterType":"Clone",
|
||||
"tags":["after-tag"],
|
||||
"hobbies":["running"],
|
||||
"values":["courage"],
|
||||
"goals":["success"],
|
||||
"relationships":[{
|
||||
"personName":"Jin",
|
||||
"relationshipName":"rival",
|
||||
"description":"friendly rival",
|
||||
"importance":7,
|
||||
"relationshipType":"RIVAL",
|
||||
"currentStatus":"ACTIVE"
|
||||
}],
|
||||
"personalities":[{"trait":"bold","description":"bold description"}],
|
||||
"backgrounds":[{"topic":"school","description":"academy"}],
|
||||
"memories":[{"title":"victory","content":"first win","emotion":"proud"}]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -698,31 +1215,34 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.name").value("v2-character-updated"))
|
||||
.andExpect(jsonPath("$.data.creatorNickname").value("v2-character-updated"))
|
||||
.andExpect(jsonPath("$.data.creatorIntroduce").value("after description"))
|
||||
.andReturn()
|
||||
val updateTimestamp = objectMapper.readTree(updateResponse.response.contentAsString)
|
||||
.path("data")
|
||||
.path("updatedAtUtc")
|
||||
.asText()
|
||||
|
||||
val detailResponse = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
val detailTimestamp = objectMapper.readTree(detailResponse.response.contentAsString)
|
||||
.path("data")
|
||||
.path("updatedAtUtc")
|
||||
.asText()
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.clear()
|
||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||
val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow()
|
||||
|
||||
assertEquals(detailTimestamp, updateTimestamp)
|
||||
assertEquals("v2-character-updated", saved.name)
|
||||
assertEquals("after prompt", saved.systemPrompt)
|
||||
assertEquals("after description", saved.description)
|
||||
assertEquals(29, saved.age)
|
||||
assertEquals("여성", saved.gender)
|
||||
assertEquals("ENTJ", saved.mbti)
|
||||
assertEquals("direct", saved.speechPattern)
|
||||
assertEquals("formal", saved.speechStyle)
|
||||
assertEquals("black hair", saved.appearance)
|
||||
assertEquals("updated title", saved.originalTitle)
|
||||
assertEquals("updated-link", saved.originalLink)
|
||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
||||
assertEquals("Clone", saved.characterType.name)
|
||||
assertEquals(listOf("after-tag"), saved.tagMappings.map { it.tag.tag })
|
||||
assertEquals(listOf("running"), saved.hobbyMappings.map { it.hobby.hobby })
|
||||
assertEquals(listOf("courage"), saved.valueMappings.map { it.value.value })
|
||||
assertEquals(listOf("success"), saved.goalMappings.map { it.goal.goal })
|
||||
assertEquals(listOf("Jin"), saved.relationships.map { it.personName })
|
||||
assertEquals(listOf("bold"), saved.personalities.map { it.trait })
|
||||
assertEquals(listOf("school"), saved.backgrounds.map { it.topic })
|
||||
assertEquals(listOf("victory"), saved.memories.map { it.title })
|
||||
assertTrue(saved.updatedAt!!.isAfter(beforeUpdatedAt))
|
||||
assertEquals(saved.imagePath, creatorMember.profileImage)
|
||||
assertEquals(saved.name, creatorMember.nickname)
|
||||
assertEquals(saved.description, creatorMember.introduce)
|
||||
@@ -872,14 +1392,14 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
"""{"isActive":false}""".toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.isActive").value(false))
|
||||
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
@@ -907,15 +1427,19 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
)
|
||||
)
|
||||
|
||||
private fun requestPart(request: String): MockMultipartFile {
|
||||
private fun requestPart(request: String, contentType: String? = MediaType.APPLICATION_JSON_VALUE): MockMultipartFile {
|
||||
return MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.TEXT_PLAIN_VALUE,
|
||||
contentType,
|
||||
request.trimIndent().toByteArray(StandardCharsets.UTF_8)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImagePart(): MockMultipartFile {
|
||||
return MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))
|
||||
}
|
||||
|
||||
private fun saveAudioContent(creator: Member): AudioContent {
|
||||
val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
@@ -1050,7 +1574,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
characterMapper = characterMapper,
|
||||
originalWorkService = originalWorkService,
|
||||
creatorMemberService = creatorMemberService,
|
||||
applicationEventPublisher = publisher
|
||||
applicationEventPublisher = publisher,
|
||||
imageHost = "https://cdn.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1081,6 +1606,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val NULL_SUCCESS_RESPONSE =
|
||||
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||
private val externalRequestCount = AtomicInteger()
|
||||
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||
createContext("/api/characters") { exchange ->
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.hamcrest.Matchers.endsWith
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
@@ -46,84 +50,215 @@ import javax.persistence.EntityManager
|
||||
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다")
|
||||
fun shouldNormalizeListPageAndMinimumSize() {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
@DisplayName("목록은 레거시 totalCount와 content의 exact JSON 필드를 반환한다")
|
||||
fun shouldReturnLegacyListContract() {
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-list-character",
|
||||
name = "v2-list-character",
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
systemPrompt = "prompt",
|
||||
age = 21,
|
||||
gender = "여성",
|
||||
mbti = "INFP",
|
||||
speechPattern = "polite",
|
||||
speechStyle = "soft",
|
||||
tags = listOf("list-tag")
|
||||
)
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
val response = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters")
|
||||
.param("page", "-1")
|
||||
.param("page", "0")
|
||||
.param("size", "1")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].name").value("v2-list-character"))
|
||||
.andExpect(jsonPath("$.data.items[0].creatorProfileImageUrl").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.items[0].creatorIntroduce").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.items[0].updatedAtUtc").doesNotExist())
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
val item = data.path("content").path(0)
|
||||
|
||||
assertEquals(setOf("totalCount", "content"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"name",
|
||||
"imageUrl",
|
||||
"description",
|
||||
"gender",
|
||||
"age",
|
||||
"mbti",
|
||||
"speechStyle",
|
||||
"speechPattern",
|
||||
"region",
|
||||
"tags",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
),
|
||||
item.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals(character.id, item.path("id").asLong())
|
||||
assertEquals("v2-list-character", item.path("name").asText())
|
||||
assertEquals(listOf("list-tag"), item.path("tags").map { it.asText() })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("검색은 최대 size를 50으로 보정한다")
|
||||
fun shouldNormalizeSearchMaximumSize() {
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
@DisplayName("목록은 OpenAPI minimum인 size 1을 그대로 적용한다")
|
||||
fun shouldHonorLegacyMinimumListSize() {
|
||||
repeat(2) { index ->
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-list-size-$index",
|
||||
name = "v2-list-size-$index",
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
}
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters")
|
||||
.param("size", "1")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(2))
|
||||
.andExpect(jsonPath("$.data.content.length()").value(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("검색은 레거시 searchTerm query를 사용한다")
|
||||
fun shouldSearchByLegacySearchTerm() {
|
||||
val matchingCharacter = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-search-character",
|
||||
name = "v2-search-character",
|
||||
description = "find this character",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-non-matching-character",
|
||||
name = "v2-non-matching-character",
|
||||
description = "different description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters")
|
||||
.param("search", "find this")
|
||||
.param("searchTerm", "find this")
|
||||
.param("size", "100")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.size").value(50))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].externalCharacterId").value("v2-search-character"))
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(matchingCharacter.id))
|
||||
.andExpect(jsonPath("$.data.content[0].name").value("v2-search-character"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다")
|
||||
fun shouldReturnDetailForAiCharacter() {
|
||||
@DisplayName("상세는 레거시 전체 필드와 nested 배열을 exact JSON으로 반환한다")
|
||||
fun shouldReturnLegacyDetailContract() {
|
||||
val originalWork = OriginalWork(
|
||||
title = "detail original work",
|
||||
contentType = "webtoon",
|
||||
category = "fantasy",
|
||||
isAdult = false,
|
||||
description = "original work description"
|
||||
)
|
||||
entityManager.persist(originalWork)
|
||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-detail-character",
|
||||
name = "v2-detail-character",
|
||||
description = "detail description",
|
||||
systemPrompt = "prompt"
|
||||
systemPrompt = "detail prompt",
|
||||
age = 22,
|
||||
gender = "여성",
|
||||
mbti = "ENFP",
|
||||
speechPattern = "bright",
|
||||
speechStyle = "friendly",
|
||||
appearance = "silver hair",
|
||||
region = "JP",
|
||||
tags = listOf("detail-tag"),
|
||||
hobbies = listOf("reading"),
|
||||
values = listOf("honesty"),
|
||||
goals = listOf("friendship"),
|
||||
memories = listOf(Triple("memory title", "memory content", "happy")),
|
||||
personalities = listOf(Pair("kind", "kind description")),
|
||||
backgrounds = listOf(Pair("hometown", "moon city")),
|
||||
relationships = listOf(
|
||||
ChatCharacterRelationshipRequest(
|
||||
personName = "Mina",
|
||||
relationshipName = "friend",
|
||||
description = "best friend",
|
||||
importance = 10,
|
||||
relationshipType = "ALLY",
|
||||
currentStatus = "ACTIVE"
|
||||
)
|
||||
)
|
||||
)
|
||||
character.imagePath = "characters/${character.id}/detail.png"
|
||||
character.originalWork = originalWork
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
val response = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.characterId").value(character.id))
|
||||
.andExpect(jsonPath("$.data.externalCharacterId").value("v2-detail-character"))
|
||||
.andExpect(jsonPath("$.data.creatorMemberId").value(character.creatorMember!!.id))
|
||||
.andExpect(jsonPath("$.data.creatorNickname").value("v2-detail-character"))
|
||||
.andExpect(jsonPath("$.data.creatorProfileImageUrl").value(endsWith("/profile/default-profile.png")))
|
||||
.andExpect(jsonPath("$.data.creatorIntroduce").value("detail description"))
|
||||
.andExpect(jsonPath("$.data.isActive").value(true))
|
||||
.andExpect(jsonPath("$.data.createdAtUtc").value(endsWith("Z")))
|
||||
.andExpect(jsonPath("$.data.updatedAtUtc").value(endsWith("Z")))
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"characterUUID",
|
||||
"name",
|
||||
"imageUrl",
|
||||
"description",
|
||||
"systemPrompt",
|
||||
"characterType",
|
||||
"age",
|
||||
"gender",
|
||||
"mbti",
|
||||
"speechPattern",
|
||||
"speechStyle",
|
||||
"appearance",
|
||||
"region",
|
||||
"isActive",
|
||||
"tags",
|
||||
"hobbies",
|
||||
"values",
|
||||
"goals",
|
||||
"relationships",
|
||||
"personalities",
|
||||
"backgrounds",
|
||||
"memories",
|
||||
"originalWork"
|
||||
),
|
||||
data.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals(character.id, data.path("id").asLong())
|
||||
assertEquals("v2-detail-character", data.path("characterUUID").asText())
|
||||
assertEquals("detail prompt", data.path("systemPrompt").asText())
|
||||
assertEquals("Character", data.path("characterType").asText())
|
||||
assertEquals(listOf("detail-tag"), data.path("tags").map { it.asText() })
|
||||
assertEquals(
|
||||
setOf("personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"),
|
||||
data.path("relationships").path(0).fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals("Mina", data.path("relationships").path(0).path("personName").asText())
|
||||
assertEquals(setOf("trait", "description"), data.path("personalities").path(0).fieldNames().asSequence().toSet())
|
||||
assertEquals(setOf("topic", "description"), data.path("backgrounds").path(0).fieldNames().asSequence().toSet())
|
||||
assertEquals(setOf("title", "content", "emotion"), data.path("memories").path(0).fieldNames().asSequence().toSet())
|
||||
assertEquals(setOf("id", "imageUrl", "title"), data.path("originalWork").fieldNames().asSequence().toSet())
|
||||
assertEquals(originalWork.id, data.path("originalWork").path("id").asLong())
|
||||
assertTrue(data.path("imageUrl").asText().endsWith("characters/${character.id}/detail.png"))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminOriginalWorkSearchTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("원작 검색은 제목·콘텐츠 타입·카테고리 부분 검색과 레거시 13개 필드 직접 배열을 반환한다")
|
||||
fun shouldSearchOriginalWorksByLegacyFieldsAndReturnDirectArray() {
|
||||
val titleMatch = saveOriginalWork("p2-r9 moon title", "webtoon", "romance")
|
||||
val typeMatch = saveOriginalWork("another title", "p2-r9 moon audio", "fantasy")
|
||||
val categoryMatch = saveOriginalWork("third title", "novel", "p2-r9 moon category")
|
||||
saveOriginalWork("deleted p2-r9 moon title", "webtoon", "romance", isDeleted = true)
|
||||
saveOriginalWork("not matched", "novel", "drama")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val response = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.param("searchTerm", "p2-r9 moon")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.length()").value(3))
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
val ids = data.map { it.path("id").asLong() }.toSet()
|
||||
|
||||
assertEquals(setOf(titleMatch.id, typeMatch.id, categoryMatch.id), ids)
|
||||
assertEquals(
|
||||
setOf(
|
||||
"id",
|
||||
"title",
|
||||
"contentType",
|
||||
"category",
|
||||
"isAdult",
|
||||
"description",
|
||||
"originalWork",
|
||||
"originalLink",
|
||||
"writer",
|
||||
"studio",
|
||||
"originalLinks",
|
||||
"tags",
|
||||
"imageUrl"
|
||||
),
|
||||
data.first().fieldNames().asSequence().toSet()
|
||||
)
|
||||
val titleItem = data.first { it.path("id").asLong() == titleMatch.id }
|
||||
assertEquals(listOf("https://example.com/p2-r9-moon-title"), titleItem.path("originalLinks").map { it.asText() })
|
||||
assertEquals(listOf("p2-r9-moon-title-tag"), titleItem.path("tags").map { it.asText() })
|
||||
assertEquals("original p2-r9 moon title", titleItem.path("originalWork").asText())
|
||||
assertEquals("https://source.example.com/p2-r9 moon title", titleItem.path("originalLink").asText())
|
||||
assertEquals("writer p2-r9 moon title", titleItem.path("writer").asText())
|
||||
assertEquals("studio p2-r9 moon title", titleItem.path("studio").asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 검색은 결과가 없으면 빈 직접 배열을 반환한다")
|
||||
fun shouldReturnEmptyArrayWhenNoOriginalWorkMatches() {
|
||||
saveOriginalWork("unrelated original", "novel", "drama")
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.param("searchTerm", "missing-p2-r9")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.length()").value(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 검색은 필수 searchTerm 누락을 신규 prefix 오류 envelope로 반환한다")
|
||||
fun shouldRejectMissingSearchTermWithLocalizedError() {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 검색은 characterId 상세 route와 충돌하지 않고 ADMIN 이중 인가와 CORS를 상속한다")
|
||||
fun shouldKeepRouteAuthorizationAndCorsContract() {
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/original-works/search")
|
||||
.param("searchTerm", "p2-r9")
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
.with(nonAdminAuthentication())
|
||||
)
|
||||
.andExpect(status().isForbidden)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("You do not have permission."))
|
||||
|
||||
mockMvc.perform(
|
||||
options("/api/v2/admin/ai-characters/original-works/search")
|
||||
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||
}
|
||||
|
||||
private fun saveOriginalWork(
|
||||
title: String,
|
||||
contentType: String,
|
||||
category: String,
|
||||
isDeleted: Boolean = false
|
||||
): OriginalWork {
|
||||
val slug = title.replace(" ", "-")
|
||||
val originalWork = OriginalWork(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
isAdult = true,
|
||||
description = "description $title",
|
||||
originalWork = "original $title",
|
||||
originalLink = "https://source.example.com/$title",
|
||||
writer = "writer $title",
|
||||
studio = "studio $title"
|
||||
)
|
||||
originalWork.imagePath = "originals/$slug.png"
|
||||
originalWork.isDeleted = isDeleted
|
||||
originalWork.originalLinks.add(OriginalWorkLink("https://example.com/$slug", originalWork))
|
||||
val tag = OriginalWorkTag("$slug-tag")
|
||||
entityManager.persist(tag)
|
||||
originalWork.tagMappings.add(OriginalWorkTagMapping(originalWork, tag))
|
||||
entityManager.persist(originalWork)
|
||||
return originalWork
|
||||
}
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
|
||||
private fun nonAdminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "user@example.com",
|
||||
password = "password",
|
||||
nickname = "user",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_USER"))
|
||||
)
|
||||
)
|
||||
|
||||
private companion object {
|
||||
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user