test #443
@@ -1,7 +1,11 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
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.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.http.MediaType
|
||||||
|
import org.springframework.web.HttpMediaTypeNotSupportedException
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
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.RequestPart
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v2/admin/ai-characters")
|
@RequestMapping("/api/v2/admin/ai-characters")
|
||||||
@@ -19,11 +24,18 @@ class AiCharacterAdminCharacterController(
|
|||||||
) {
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun list(
|
fun list(
|
||||||
@RequestParam(required = false) search: String?,
|
@RequestParam(required = false) searchTerm: String?,
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
): ApiResponse<AiCharacterAdminListResponse> {
|
): 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]+}")
|
@GetMapping("/{characterId:[0-9]+}")
|
||||||
@@ -33,18 +45,46 @@ class AiCharacterAdminCharacterController(
|
|||||||
|
|
||||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
fun create(
|
fun create(
|
||||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
@RequestPart("image") image: MultipartFile,
|
||||||
@RequestPart("request") request: String
|
@RequestPart("request") request: String,
|
||||||
): ApiResponse<AiCharacterAdminCharacterResponse> {
|
multipartRequest: MultipartHttpServletRequest
|
||||||
return ApiResponse.ok(facade.create(image, request))
|
): ApiResponse<Nothing> {
|
||||||
|
requireAllowedMultipartParts(multipartRequest)
|
||||||
|
requireJsonRequestPart(multipartRequest)
|
||||||
|
facade.create(image, request)
|
||||||
|
return ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
@PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
fun update(
|
fun update(
|
||||||
@PathVariable characterId: Long,
|
@PathVariable characterId: Long,
|
||||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||||
@RequestPart("request") request: String
|
@RequestPart("request") request: String,
|
||||||
): ApiResponse<AiCharacterAdminCharacterResponse> {
|
multipartRequest: MultipartHttpServletRequest
|
||||||
return ApiResponse.ok(facade.update(characterId, image, request))
|
): 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
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||||
|
|
||||||
data class AiCharacterAdminListResponse(
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
|
||||||
val totalCount: Long,
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||||
val items: List<AiCharacterAdminCharacterListItemResponse>,
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||||
val page: Int,
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
val size: Int,
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
||||||
val hasNext: Boolean
|
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(
|
typealias AiCharacterAdminListResponse = ChatCharacterListPageResponse
|
||||||
val characterId: Long,
|
typealias AiCharacterAdminCharacterListItemResponse = ChatCharacterListResponse
|
||||||
val name: String,
|
typealias AiCharacterAdminCharacterResponse = ChatCharacterDetailResponse
|
||||||
val description: String,
|
typealias AiCharacterAdminCharacterCreateRequest = ChatCharacterRegisterRequest
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AiCharacterAdminCharacterUpdateRequest(
|
data class AiCharacterAdminCharacterUpdateRequest(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val systemPrompt: String? = null,
|
val systemPrompt: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val externalCharacterId: String? = null,
|
|
||||||
val age: String? = null,
|
val age: String? = null,
|
||||||
val gender: String? = null,
|
val gender: String? = null,
|
||||||
val mbti: String? = null,
|
val mbti: String? = null,
|
||||||
@@ -84,33 +33,8 @@ data class AiCharacterAdminCharacterUpdateRequest(
|
|||||||
val hobbies: List<String>? = null,
|
val hobbies: List<String>? = null,
|
||||||
val values: List<String>? = null,
|
val values: List<String>? = null,
|
||||||
val goals: List<String>? = null,
|
val goals: List<String>? = null,
|
||||||
val relationships: List<AiCharacterAdminCharacterRelationshipRequest>? = null,
|
val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||||
val personalities: List<AiCharacterAdminCharacterPersonalityRequest>? = null,
|
val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||||
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest>? = null,
|
val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||||
val memories: List<AiCharacterAdminCharacterMemoryRequest>? = null
|
val memories: List<ChatCharacterMemoryRequest>? = 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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException
|
import com.fasterxml.jackson.core.JsonProcessingException
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
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.admin.chat.original.service.AdminOriginalWorkService
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
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.ChatCharacterCreatorMemberService
|
||||||
@@ -14,6 +16,7 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
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.application.AiCharacterAdminTargetResolver
|
||||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
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.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Sort
|
import org.springframework.data.domain.Sort
|
||||||
@@ -33,14 +36,16 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
private val characterMapper: AiCharacterAdminCharacterMapper,
|
private val characterMapper: AiCharacterAdminCharacterMapper,
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
private val creatorMemberService: ChatCharacterCreatorMemberService,
|
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)
|
@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 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(
|
val characters = chatCharacterRepository.searchAiCharacters(
|
||||||
searchTerm = search?.trim().orEmpty(),
|
searchTerm = searchTerm?.trim().orEmpty(),
|
||||||
role = MemberRole.CREATOR,
|
role = MemberRole.CREATOR,
|
||||||
memberKind = MemberKind.AI_CHARACTER,
|
memberKind = MemberKind.AI_CHARACTER,
|
||||||
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt"))
|
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||||
@@ -48,10 +53,7 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
|
|
||||||
return AiCharacterAdminListResponse(
|
return AiCharacterAdminListResponse(
|
||||||
totalCount = characters.totalElements,
|
totalCount = characters.totalElements,
|
||||||
items = characters.content.map(characterMapper::toListItemResponse),
|
content = characters.content.map(characterMapper::toListItemResponse)
|
||||||
page = normalizedPage,
|
|
||||||
size = normalizedSize,
|
|
||||||
hasNext = characters.hasNext()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +62,16 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter)
|
return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional(readOnly = true)
|
||||||
fun create(image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
|
fun searchOriginalWorks(searchTerm: String): List<OriginalWorkResponse> {
|
||||||
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
|
return originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||||
if (request.externalCharacterId != null || request.isActive != null) {
|
.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
throw invalidRequest()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(image: MultipartFile?, requestString: String) {
|
||||||
|
if (image?.isEmpty == true) throw invalidRequest()
|
||||||
|
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
|
||||||
val characterType = characterMapper.toCharacterType(request)
|
val characterType = characterMapper.toCharacterType(request)
|
||||||
if (chatCharacterService.findByName(request.name) != null) {
|
if (chatCharacterService.findByName(request.name) != null) {
|
||||||
throw invalidRequest()
|
throw invalidRequest()
|
||||||
@@ -94,7 +100,7 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||||
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||||
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||||
relationships = request.relationships.map(characterMapper::toLegacyRequest)
|
relationships = request.relationships
|
||||||
)
|
)
|
||||||
|
|
||||||
if (image?.isEmpty == false) {
|
if (image?.isEmpty == false) {
|
||||||
@@ -113,50 +119,47 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return characterMapper.toResponse(chatCharacter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun update(characterId: Long, image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
|
fun update(characterId: Long, image: MultipartFile?, requestString: String) {
|
||||||
val target = targetResolver.resolve(characterId)
|
val target = targetResolver.resolve(characterId)
|
||||||
val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java)
|
val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java)
|
||||||
if (request.externalCharacterId != null) {
|
if (request.isActive == false && image?.isEmpty == false) {
|
||||||
throw invalidRequest()
|
throw invalidRequest()
|
||||||
}
|
}
|
||||||
characterMapper.validateCharacterType(request)
|
val effectiveRequest = if (request.isActive == false) {
|
||||||
if (
|
AiCharacterAdminCharacterUpdateRequest(isActive = false)
|
||||||
request.isActive == false &&
|
} else {
|
||||||
(characterMapper.hasNonSoftDeleteChanges(request) || image?.isEmpty == false)
|
request
|
||||||
) {
|
|
||||||
throw invalidRequest()
|
|
||||||
}
|
}
|
||||||
|
characterMapper.validateCharacterType(effectiveRequest)
|
||||||
if (
|
if (
|
||||||
!characterMapper.hasExternalChanges(request) &&
|
!characterMapper.hasExternalChanges(effectiveRequest) &&
|
||||||
!characterMapper.hasDbOnlyChanges(request) &&
|
!characterMapper.hasDbOnlyChanges(effectiveRequest) &&
|
||||||
image?.isEmpty != false
|
image?.isEmpty != false
|
||||||
) {
|
) {
|
||||||
throw invalidRequest()
|
throw invalidRequest()
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
request.name != null && request.name != target.chatCharacter.name &&
|
effectiveRequest.name != null && effectiveRequest.name != target.chatCharacter.name &&
|
||||||
chatCharacterService.findByName(request.name) != null
|
chatCharacterService.findByName(effectiveRequest.name) != null
|
||||||
) {
|
) {
|
||||||
throw invalidRequest()
|
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
|
val requestName = if (effectiveRequest.isActive == false) target.chatCharacter.name else effectiveRequest.name
|
||||||
if (characterMapper.hasExternalChanges(request)) {
|
if (characterMapper.hasExternalChanges(effectiveRequest)) {
|
||||||
externalApiClient.update(target.chatCharacter, request, requestName)
|
externalApiClient.update(target.chatCharacter, effectiveRequest, requestName)
|
||||||
}
|
}
|
||||||
|
|
||||||
val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null
|
val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null
|
||||||
val updatedCharacter = chatCharacterService.updateChatCharacterWithDetails(
|
chatCharacterService.updateChatCharacterWithDetails(
|
||||||
imagePath = imagePath,
|
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()
|
chatCharacterRepository.flush()
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
LanguageTranslationEvent(
|
LanguageTranslationEvent(
|
||||||
@@ -165,13 +168,14 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
waitTransactionCommit = true
|
waitTransactionCommit = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return characterMapper.toResponse(updatedCharacter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
|
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
|
||||||
return try {
|
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) {
|
} catch (_: JsonProcessingException) {
|
||||||
throw invalidRequest()
|
throw invalidRequest()
|
||||||
}
|
}
|
||||||
@@ -182,7 +186,6 @@ class AiCharacterAdminCharacterFacade(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MINIMUM_PAGE_SIZE = 20
|
private const val MINIMUM_PAGE_SIZE = 1
|
||||||
private const val MAXIMUM_PAGE_SIZE = 50
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
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.ChatCharacterDetailResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
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.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
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 kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
@@ -18,38 +15,11 @@ class AiCharacterAdminCharacterMapper(
|
|||||||
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
|
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
|
||||||
) {
|
) {
|
||||||
fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse {
|
fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse {
|
||||||
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
|
return ChatCharacterListResponse.from(chatCharacter, imageHost)
|
||||||
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 {
|
fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse {
|
||||||
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
|
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||||
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 {
|
fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType {
|
||||||
@@ -61,11 +31,7 @@ class AiCharacterAdminCharacterMapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
||||||
return hasRegularExternalChanges(request) || request.isActive == false
|
return hasRegularExternalChanges(request) || request.isActive != null
|
||||||
}
|
|
||||||
|
|
||||||
fun hasNonSoftDeleteChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
|
||||||
return hasRegularExternalChanges(request) || hasDbOnlyChanges(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
|
||||||
@@ -106,21 +72,10 @@ class AiCharacterAdminCharacterMapper(
|
|||||||
hobbies = request.hobbies,
|
hobbies = request.hobbies,
|
||||||
values = request.values,
|
values = request.values,
|
||||||
goals = request.goals,
|
goals = request.goals,
|
||||||
relationships = request.relationships?.map(::toLegacyRequest),
|
relationships = request.relationships,
|
||||||
personalities = request.personalities?.map { ChatCharacterPersonalityRequest(it.trait, it.description) },
|
personalities = request.personalities,
|
||||||
backgrounds = request.backgrounds?.map { ChatCharacterBackgroundRequest(it.topic, it.description) },
|
backgrounds = request.backgrounds,
|
||||||
memories = request.memories?.map { ChatCharacterMemoryRequest(it.title, it.content, it.emotion) }
|
memories = request.memories
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.DisplayName
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
import org.junit.jupiter.params.provider.CsvSource
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
import org.mockito.ArgumentCaptor
|
import org.mockito.ArgumentCaptor
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
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.HttpMethod
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.mock.web.MockMultipartFile
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.mock.web.MockPart
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
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.ResultActions
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
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.jsonPath
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import org.springframework.transaction.annotation.Propagation
|
import org.springframework.transaction.annotation.Propagation
|
||||||
@@ -97,11 +101,13 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
externalRequestCount.set(0)
|
externalRequestCount.set(0)
|
||||||
Mockito.reset(amazonS3Client)
|
Mockito.reset(amazonS3Client)
|
||||||
Mockito.reset(applicationEventPublisher)
|
Mockito.reset(applicationEventPublisher)
|
||||||
|
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||||
|
.thenReturn(java.net.URL("https", "cdn.example.com", "/image.png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("생성은 원작과 AI creatorMember 표시 정보를 만든다")
|
@DisplayName("생성은 필수 image와 레거시 전체 request를 반영하고 data null을 반환한다")
|
||||||
fun shouldCreateCharacterAndReturnDetailWithAiCreatorMember() {
|
fun shouldCreateCharacterFromLegacyRequestAndReturnNullData() {
|
||||||
val originalWork = OriginalWork(
|
val originalWork = OriginalWork(
|
||||||
title = "v2-original-work",
|
title = "v2-original-work",
|
||||||
contentType = "webtoon",
|
contentType = "webtoon",
|
||||||
@@ -113,32 +119,48 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
|
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
MockMultipartFile(
|
requestPart(
|
||||||
"request",
|
|
||||||
"request.json",
|
|
||||||
MediaType.TEXT_PLAIN_VALUE,
|
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"name":"v2-created-character",
|
"name":"v2-created-character",
|
||||||
"systemPrompt":"prompt",
|
"systemPrompt":"prompt",
|
||||||
"description":"created description",
|
"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},
|
"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())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(content().json(NULL_SUCCESS_RESPONSE, 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.flush()
|
||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
@@ -150,6 +172,160 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
assertEquals("v2-created-character", creatorMember.nickname)
|
assertEquals("v2-created-character", creatorMember.nickname)
|
||||||
assertEquals("created description", creatorMember.introduce)
|
assertEquals("created description", creatorMember.introduce)
|
||||||
assertEquals(originalWork.id, saved.originalWork!!.id)
|
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
|
@Test
|
||||||
@@ -157,7 +333,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
fun shouldPublishLanguageDetectEventOnCreate() {
|
fun shouldPublishLanguageDetectEventOnCreate() {
|
||||||
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
|
||||||
val response = createFacadeForEventPayloadAssertion(publisher).create(
|
createFacadeForEventPayloadAssertion(publisher).create(
|
||||||
image = null,
|
image = null,
|
||||||
requestString = """
|
requestString = """
|
||||||
{
|
{
|
||||||
@@ -171,7 +347,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
||||||
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
|
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
|
||||||
val event = eventCaptor.value as LanguageDetectEvent
|
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)
|
assertEquals("event description", event.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +361,12 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) {
|
fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
MockMultipartFile(
|
MockMultipartFile(
|
||||||
"request",
|
"request",
|
||||||
"request.json",
|
"request.json",
|
||||||
MediaType.TEXT_PLAIN_VALUE,
|
MediaType.APPLICATION_JSON_VALUE,
|
||||||
"{".toByteArray(StandardCharsets.UTF_8)
|
"{".toByteArray(StandardCharsets.UTF_8)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -223,7 +400,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
MockMultipartFile(
|
MockMultipartFile(
|
||||||
"request",
|
"request",
|
||||||
"request.json",
|
"request.json",
|
||||||
MediaType.TEXT_PLAIN_VALUE,
|
MediaType.APPLICATION_JSON_VALUE,
|
||||||
"{".toByteArray(StandardCharsets.UTF_8)
|
"{".toByteArray(StandardCharsets.UTF_8)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -235,11 +412,271 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.message").value(message))
|
.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
|
@Test
|
||||||
@DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다")
|
@DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다")
|
||||||
fun shouldRejectServerOwnedCreateFields() {
|
fun shouldRejectServerOwnedCreateFields() {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -268,6 +705,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
fun shouldRejectInvalidCharacterTypeBeforeExternalApi() {
|
fun shouldRejectInvalidCharacterTypeBeforeExternalApi() {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -304,6 +742,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
|
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -330,6 +769,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
fun shouldRejectMissingOriginalWorkBeforeExternalApi() {
|
fun shouldRejectMissingOriginalWorkBeforeExternalApi() {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -362,6 +802,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) {
|
fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -430,7 +871,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||||
)
|
)
|
||||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다")
|
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성과 S3 호출만 남긴다")
|
||||||
fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) {
|
fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) {
|
||||||
val characterName = "v2-create-event-failure-$language"
|
val characterName = "v2-create-event-failure-$language"
|
||||||
val originalWork = createOriginalWork("v2-create-event-original-work-$language")
|
val originalWork = createOriginalWork("v2-create-event-original-work-$language")
|
||||||
@@ -442,6 +883,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
try {
|
try {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -466,7 +908,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
|
|
||||||
assertEquals(1, externalRequestCount.get())
|
assertEquals(1, externalRequestCount.get())
|
||||||
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java))
|
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!!)
|
assertNoCreatedCharacterState(characterName, originalWork.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +929,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
|
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart("/api/v2/admin/ai-characters")
|
multipart("/api/v2/admin/ai-characters")
|
||||||
|
.file(createImagePart())
|
||||||
.file(
|
.file(
|
||||||
requestPart(
|
requestPart(
|
||||||
"""
|
"""
|
||||||
@@ -547,12 +990,64 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("수정의 isActive false는 다른 변경이나 이미지를 함께 받지 않는다")
|
@DisplayName("수정의 isActive false는 다른 optional JSON 필드를 허용하고 비활성화만 반영한다")
|
||||||
fun shouldRejectMixedSoftDeleteBeforeExternalAndImageSideEffects() {
|
fun shouldIgnoreOtherFieldsInMixedSoftDeleteRequest() {
|
||||||
|
val originalWork = createOriginalWork("v2-mixed-soft-delete-original")
|
||||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
characterUUID = "v2-mixed-soft-delete",
|
characterUUID = "v2-mixed-soft-delete",
|
||||||
name = "v2-mixed-soft-delete",
|
name = "v2-mixed-soft-delete",
|
||||||
description = "before description",
|
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"
|
systemPrompt = "prompt"
|
||||||
)
|
)
|
||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
@@ -560,24 +1055,18 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
|
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||||
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
.file(requestPart("""{"isActive":true}"""))
|
||||||
.file(
|
|
||||||
requestPart(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"isActive":false,
|
|
||||||
"description":"ignored description"
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isBadRequest)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.success").value(false))
|
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
assertEquals(0, externalRequestCount.get())
|
entityManager.flush()
|
||||||
assertTrue(chatCharacterRepository.findById(character.id!!).orElseThrow().isActive)
|
entityManager.clear()
|
||||||
|
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||||
|
assertTrue(saved.isActive)
|
||||||
|
assertEquals("before description", saved.description)
|
||||||
|
assertEquals(1, externalRequestCount.get())
|
||||||
Mockito.verifyNoInteractions(amazonS3Client)
|
Mockito.verifyNoInteractions(amazonS3Client)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +1148,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -669,20 +1159,21 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("수정은 이미지와 표시 정보를 동기화하고 flush된 updatedAtUtc를 반환한다")
|
@DisplayName("수정은 레거시 optional 필드를 반영하고 data null을 반환한다")
|
||||||
fun shouldUpdateCharacterImageAndCreatorDisplayFieldsWithFlushedTimestamp() {
|
fun shouldUpdateCharacterFromLegacyOptionalFieldsAndReturnNullData() {
|
||||||
|
val originalWork = createOriginalWork("v2-update-original-work")
|
||||||
val character = chatCharacterService.createChatCharacterWithDetails(
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
characterUUID = "v2-character-update",
|
characterUUID = "v2-character-update",
|
||||||
name = "v2-character-update",
|
name = "v2-character-update",
|
||||||
description = "before description",
|
description = "before description",
|
||||||
systemPrompt = "prompt"
|
systemPrompt = "before prompt",
|
||||||
|
tags = listOf("before-tag")
|
||||||
)
|
)
|
||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
|
val beforeUpdatedAt = character.updatedAt!!
|
||||||
Thread.sleep(5)
|
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}")
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
|
||||||
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
|
||||||
.file(
|
.file(
|
||||||
@@ -690,7 +1181,33 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"name":"v2-character-updated",
|
"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()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
@@ -698,31 +1215,34 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.data.name").value("v2-character-updated"))
|
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||||
.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()
|
entityManager.clear()
|
||||||
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
|
||||||
val creatorMember = memberRepository.findById(saved.creatorMember!!.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.imagePath, creatorMember.profileImage)
|
||||||
assertEquals(saved.name, creatorMember.nickname)
|
assertEquals(saved.name, creatorMember.nickname)
|
||||||
assertEquals(saved.description, creatorMember.introduce)
|
assertEquals(saved.description, creatorMember.introduce)
|
||||||
@@ -872,14 +1392,14 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
MockMultipartFile(
|
MockMultipartFile(
|
||||||
"request",
|
"request",
|
||||||
"request.json",
|
"request.json",
|
||||||
MediaType.TEXT_PLAIN_VALUE,
|
MediaType.APPLICATION_JSON_VALUE,
|
||||||
"""{"isActive":false}""".toByteArray(StandardCharsets.UTF_8)
|
"""{"isActive":false}""".toByteArray(StandardCharsets.UTF_8)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.data.isActive").value(false))
|
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
entityManager.clear()
|
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(
|
return MockMultipartFile(
|
||||||
"request",
|
"request",
|
||||||
"request.json",
|
"request.json",
|
||||||
MediaType.TEXT_PLAIN_VALUE,
|
contentType,
|
||||||
request.trimIndent().toByteArray(StandardCharsets.UTF_8)
|
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 {
|
private fun saveAudioContent(creator: Member): AudioContent {
|
||||||
val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true)
|
val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true)
|
||||||
entityManager.persist(theme)
|
entityManager.persist(theme)
|
||||||
@@ -1050,7 +1574,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
characterMapper = characterMapper,
|
characterMapper = characterMapper,
|
||||||
originalWorkService = originalWorkService,
|
originalWorkService = originalWorkService,
|
||||||
creatorMemberService = creatorMemberService,
|
creatorMemberService = creatorMemberService,
|
||||||
applicationEventPublisher = publisher
|
applicationEventPublisher = publisher,
|
||||||
|
imageHost = "https://cdn.example.com"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,6 +1606,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val NULL_SUCCESS_RESPONSE =
|
||||||
|
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||||
private val externalRequestCount = AtomicInteger()
|
private val externalRequestCount = AtomicInteger()
|
||||||
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
|
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
|
||||||
createContext("/api/characters") { exchange ->
|
createContext("/api/characters") { exchange ->
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.sun.net.httpserver.HttpServer
|
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.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberKind
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
import org.hamcrest.Matchers.endsWith
|
|
||||||
import org.junit.jupiter.api.AfterAll
|
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.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
@@ -46,84 +50,215 @@ import javax.persistence.EntityManager
|
|||||||
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
|
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
|
||||||
private val mockMvc: MockMvc,
|
private val mockMvc: MockMvc,
|
||||||
private val chatCharacterService: ChatCharacterService,
|
private val chatCharacterService: ChatCharacterService,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
private val entityManager: EntityManager
|
private val entityManager: EntityManager
|
||||||
) {
|
) {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다")
|
@DisplayName("목록은 레거시 totalCount와 content의 exact JSON 필드를 반환한다")
|
||||||
fun shouldNormalizeListPageAndMinimumSize() {
|
fun shouldReturnLegacyListContract() {
|
||||||
chatCharacterService.createChatCharacterWithDetails(
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
characterUUID = "v2-list-character",
|
characterUUID = "v2-list-character",
|
||||||
name = "v2-list-character",
|
name = "v2-list-character",
|
||||||
description = "description",
|
description = "description",
|
||||||
systemPrompt = "prompt"
|
systemPrompt = "prompt",
|
||||||
|
age = 21,
|
||||||
|
gender = "여성",
|
||||||
|
mbti = "INFP",
|
||||||
|
speechPattern = "polite",
|
||||||
|
speechStyle = "soft",
|
||||||
|
tags = listOf("list-tag")
|
||||||
)
|
)
|
||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
|
|
||||||
mockMvc.perform(
|
val response = mockMvc.perform(
|
||||||
get("/api/v2/admin/ai-characters")
|
get("/api/v2/admin/ai-characters")
|
||||||
.param("page", "-1")
|
.param("page", "0")
|
||||||
.param("size", "1")
|
.param("size", "1")
|
||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.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.totalCount").value(1))
|
||||||
.andExpect(jsonPath("$.data.items[0].name").value("v2-list-character"))
|
.andReturn()
|
||||||
.andExpect(jsonPath("$.data.items[0].creatorProfileImageUrl").doesNotExist())
|
|
||||||
.andExpect(jsonPath("$.data.items[0].creatorIntroduce").doesNotExist())
|
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||||
.andExpect(jsonPath("$.data.items[0].updatedAtUtc").doesNotExist())
|
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
|
@Test
|
||||||
@DisplayName("검색은 최대 size를 50으로 보정한다")
|
@DisplayName("목록은 OpenAPI minimum인 size 1을 그대로 적용한다")
|
||||||
fun shouldNormalizeSearchMaximumSize() {
|
fun shouldHonorLegacyMinimumListSize() {
|
||||||
|
repeat(2) { index ->
|
||||||
chatCharacterService.createChatCharacterWithDetails(
|
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",
|
characterUUID = "v2-search-character",
|
||||||
name = "v2-search-character",
|
name = "v2-search-character",
|
||||||
description = "find this character",
|
description = "find this character",
|
||||||
systemPrompt = "prompt"
|
systemPrompt = "prompt"
|
||||||
)
|
)
|
||||||
|
chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-non-matching-character",
|
||||||
|
name = "v2-non-matching-character",
|
||||||
|
description = "different description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
|
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
get("/api/v2/admin/ai-characters")
|
get("/api/v2/admin/ai-characters")
|
||||||
.param("search", "find this")
|
.param("searchTerm", "find this")
|
||||||
.param("size", "100")
|
.param("size", "100")
|
||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.data.size").value(50))
|
|
||||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
.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
|
@Test
|
||||||
@DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다")
|
@DisplayName("상세는 레거시 전체 필드와 nested 배열을 exact JSON으로 반환한다")
|
||||||
fun shouldReturnDetailForAiCharacter() {
|
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(
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
characterUUID = "v2-detail-character",
|
characterUUID = "v2-detail-character",
|
||||||
name = "v2-detail-character",
|
name = "v2-detail-character",
|
||||||
description = "detail description",
|
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()
|
entityManager.flush()
|
||||||
|
|
||||||
mockMvc.perform(
|
val response = mockMvc.perform(
|
||||||
get("/api/v2/admin/ai-characters/${character.id}")
|
get("/api/v2/admin/ai-characters/${character.id}")
|
||||||
.with(adminAuthentication())
|
.with(adminAuthentication())
|
||||||
)
|
)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.data.characterId").value(character.id))
|
.andReturn()
|
||||||
.andExpect(jsonPath("$.data.externalCharacterId").value("v2-detail-character"))
|
|
||||||
.andExpect(jsonPath("$.data.creatorMemberId").value(character.creatorMember!!.id))
|
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||||
.andExpect(jsonPath("$.data.creatorNickname").value("v2-detail-character"))
|
|
||||||
.andExpect(jsonPath("$.data.creatorProfileImageUrl").value(endsWith("/profile/default-profile.png")))
|
assertEquals(
|
||||||
.andExpect(jsonPath("$.data.creatorIntroduce").value("detail description"))
|
setOf(
|
||||||
.andExpect(jsonPath("$.data.isActive").value(true))
|
"id",
|
||||||
.andExpect(jsonPath("$.data.createdAtUtc").value(endsWith("Z")))
|
"characterUUID",
|
||||||
.andExpect(jsonPath("$.data.updatedAtUtc").value(endsWith("Z")))
|
"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
|
@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