test #443

Merged
klaus merged 25 commits from test into main 2026-08-03 06:20:48 +00:00
7 changed files with 1085 additions and 296 deletions
Showing only changes of commit 4479c192c9 - Show all commits

View File

@@ -1,7 +1,11 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.HttpMediaTypeNotSupportedException
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -11,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
@@ -19,11 +24,18 @@ class AiCharacterAdminCharacterController(
) {
@GetMapping
fun list(
@RequestParam(required = false) search: String?,
@RequestParam(required = false) searchTerm: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminListResponse> {
return ApiResponse.ok(facade.list(search, page, size))
return ApiResponse.ok(facade.list(searchTerm, page, size))
}
@GetMapping("/original-works/search")
fun searchOriginalWorks(
@RequestParam("searchTerm") searchTerm: String
): ApiResponse<List<OriginalWorkResponse>> {
return ApiResponse.ok(facade.searchOriginalWorks(searchTerm))
}
@GetMapping("/{characterId:[0-9]+}")
@@ -33,18 +45,46 @@ class AiCharacterAdminCharacterController(
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AiCharacterAdminCharacterResponse> {
return ApiResponse.ok(facade.create(image, request))
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.create(image, request)
return ApiResponse.ok(null)
}
@PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AiCharacterAdminCharacterResponse> {
return ApiResponse.ok(facade.update(characterId, image, request))
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, image, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) {
if (multipartRequest.fileMap.keys.any { it !in CHARACTER_MULTIPART_PARTS } ||
multipartRequest.parts.any { it.name !in CHARACTER_MULTIPART_PARTS }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val CHARACTER_MULTIPART_PARTS = setOf("image", "request")
}
}

View File

@@ -1,74 +1,23 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
data class AiCharacterAdminListResponse(
val totalCount: Long,
val items: List<AiCharacterAdminCharacterListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
data class AiCharacterAdminCharacterListItemResponse(
val characterId: Long,
val name: String,
val description: String,
val imageUrl: String,
val creatorMemberId: Long,
val creatorNickname: String,
val originalWorkId: Long?,
val externalCharacterId: String,
val isActive: Boolean,
val createdAtUtc: String?
)
data class AiCharacterAdminCharacterResponse(
val characterId: Long,
val name: String,
val description: String,
val imageUrl: String,
val creatorMemberId: Long,
val creatorNickname: String,
val creatorProfileImageUrl: String,
val creatorIntroduce: String?,
val originalWorkId: Long?,
val externalCharacterId: String,
val isActive: Boolean,
val createdAtUtc: String?,
val updatedAtUtc: String?
)
data class AiCharacterAdminCharacterCreateRequest(
val name: String,
val systemPrompt: String,
val description: String,
val externalCharacterId: String? = null,
val isActive: Boolean? = null,
val age: String? = null,
val gender: String? = null,
val mbti: String? = null,
val speechPattern: String? = null,
val speechStyle: String? = null,
val appearance: String? = null,
val region: String = "KR",
val originalTitle: String? = null,
val originalLink: String? = null,
val originalWorkId: Long? = null,
val characterType: String? = null,
val tags: List<String> = emptyList(),
val hobbies: List<String> = emptyList(),
val values: List<String> = emptyList(),
val goals: List<String> = emptyList(),
val relationships: List<AiCharacterAdminCharacterRelationshipRequest> = emptyList(),
val personalities: List<AiCharacterAdminCharacterPersonalityRequest> = emptyList(),
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest> = emptyList(),
val memories: List<AiCharacterAdminCharacterMemoryRequest> = emptyList()
)
typealias AiCharacterAdminListResponse = ChatCharacterListPageResponse
typealias AiCharacterAdminCharacterListItemResponse = ChatCharacterListResponse
typealias AiCharacterAdminCharacterResponse = ChatCharacterDetailResponse
typealias AiCharacterAdminCharacterCreateRequest = ChatCharacterRegisterRequest
data class AiCharacterAdminCharacterUpdateRequest(
val name: String? = null,
val systemPrompt: String? = null,
val description: String? = null,
val externalCharacterId: String? = null,
val age: String? = null,
val gender: String? = null,
val mbti: String? = null,
@@ -84,33 +33,8 @@ data class AiCharacterAdminCharacterUpdateRequest(
val hobbies: List<String>? = null,
val values: List<String>? = null,
val goals: List<String>? = null,
val relationships: List<AiCharacterAdminCharacterRelationshipRequest>? = null,
val personalities: List<AiCharacterAdminCharacterPersonalityRequest>? = null,
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest>? = null,
val memories: List<AiCharacterAdminCharacterMemoryRequest>? = null
)
data class AiCharacterAdminCharacterRelationshipRequest(
val personName: String,
val relationshipName: String,
val description: String,
val importance: Int,
val relationshipType: String,
val currentStatus: String
)
data class AiCharacterAdminCharacterPersonalityRequest(
val trait: String,
val description: String
)
data class AiCharacterAdminCharacterBackgroundRequest(
val topic: String,
val description: String
)
data class AiCharacterAdminCharacterMemoryRequest(
val title: String,
val content: String,
val emotion: String
val relationships: List<ChatCharacterRelationshipRequest>? = null,
val personalities: List<ChatCharacterPersonalityRequest>? = null,
val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
val memories: List<ChatCharacterMemoryRequest>? = null
)

View File

@@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
@@ -14,6 +16,7 @@ import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
@@ -33,14 +36,16 @@ class AiCharacterAdminCharacterFacade(
private val characterMapper: AiCharacterAdminCharacterMapper,
private val originalWorkService: AdminOriginalWorkService,
private val creatorMemberService: ChatCharacterCreatorMemberService,
private val applicationEventPublisher: ApplicationEventPublisher
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@Transactional(readOnly = true)
fun list(search: String?, page: Int, size: Int): AiCharacterAdminListResponse {
fun list(searchTerm: String?, page: Int, size: Int): AiCharacterAdminListResponse {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE)
val normalizedSize = size.coerceAtLeast(MINIMUM_PAGE_SIZE)
val characters = chatCharacterRepository.searchAiCharacters(
searchTerm = search?.trim().orEmpty(),
searchTerm = searchTerm?.trim().orEmpty(),
role = MemberRole.CREATOR,
memberKind = MemberKind.AI_CHARACTER,
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt"))
@@ -48,10 +53,7 @@ class AiCharacterAdminCharacterFacade(
return AiCharacterAdminListResponse(
totalCount = characters.totalElements,
items = characters.content.map(characterMapper::toListItemResponse),
page = normalizedPage,
size = normalizedSize,
hasNext = characters.hasNext()
content = characters.content.map(characterMapper::toListItemResponse)
)
}
@@ -60,12 +62,16 @@ class AiCharacterAdminCharacterFacade(
return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter)
}
@Transactional
fun create(image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
if (request.externalCharacterId != null || request.isActive != null) {
throw invalidRequest()
@Transactional(readOnly = true)
fun searchOriginalWorks(searchTerm: String): List<OriginalWorkResponse> {
return originalWorkService.searchOriginalWorksAll(searchTerm)
.map { OriginalWorkResponse.from(it, imageHost) }
}
@Transactional
fun create(image: MultipartFile?, requestString: String) {
if (image?.isEmpty == true) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
val characterType = characterMapper.toCharacterType(request)
if (chatCharacterService.findByName(request.name) != null) {
throw invalidRequest()
@@ -94,7 +100,7 @@ class AiCharacterAdminCharacterFacade(
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
personalities = request.personalities.map { Pair(it.trait, it.description) },
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
relationships = request.relationships.map(characterMapper::toLegacyRequest)
relationships = request.relationships
)
if (image?.isEmpty == false) {
@@ -113,50 +119,47 @@ class AiCharacterAdminCharacterFacade(
)
)
}
return characterMapper.toResponse(chatCharacter)
}
@Transactional
fun update(characterId: Long, image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
fun update(characterId: Long, image: MultipartFile?, requestString: String) {
val target = targetResolver.resolve(characterId)
val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java)
if (request.externalCharacterId != null) {
if (request.isActive == false && image?.isEmpty == false) {
throw invalidRequest()
}
characterMapper.validateCharacterType(request)
if (
request.isActive == false &&
(characterMapper.hasNonSoftDeleteChanges(request) || image?.isEmpty == false)
) {
throw invalidRequest()
val effectiveRequest = if (request.isActive == false) {
AiCharacterAdminCharacterUpdateRequest(isActive = false)
} else {
request
}
characterMapper.validateCharacterType(effectiveRequest)
if (
!characterMapper.hasExternalChanges(request) &&
!characterMapper.hasDbOnlyChanges(request) &&
!characterMapper.hasExternalChanges(effectiveRequest) &&
!characterMapper.hasDbOnlyChanges(effectiveRequest) &&
image?.isEmpty != false
) {
throw invalidRequest()
}
if (
request.name != null && request.name != target.chatCharacter.name &&
chatCharacterService.findByName(request.name) != null
effectiveRequest.name != null && effectiveRequest.name != target.chatCharacter.name &&
chatCharacterService.findByName(effectiveRequest.name) != null
) {
throw invalidRequest()
}
request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
effectiveRequest.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
val requestName = if (request.isActive == false) request.name ?: target.chatCharacter.name else request.name
if (characterMapper.hasExternalChanges(request)) {
externalApiClient.update(target.chatCharacter, request, requestName)
val requestName = if (effectiveRequest.isActive == false) target.chatCharacter.name else effectiveRequest.name
if (characterMapper.hasExternalChanges(effectiveRequest)) {
externalApiClient.update(target.chatCharacter, effectiveRequest, requestName)
}
val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null
val updatedCharacter = chatCharacterService.updateChatCharacterWithDetails(
chatCharacterService.updateChatCharacterWithDetails(
imagePath = imagePath,
request = characterMapper.toLegacyRequest(request, characterId, requestName)
request = characterMapper.toLegacyRequest(effectiveRequest, characterId, requestName)
)
request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) }
effectiveRequest.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) }
chatCharacterRepository.flush()
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
@@ -165,13 +168,14 @@ class AiCharacterAdminCharacterFacade(
waitTransactionCommit = true
)
)
return characterMapper.toResponse(updatedCharacter)
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readValue(requestString, requestClass)
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
@@ -182,7 +186,6 @@ class AiCharacterAdminCharacterFacade(
}
companion object {
private const val MINIMUM_PAGE_SIZE = 20
private const val MAXIMUM_PAGE_SIZE = 50
private const val MINIMUM_PAGE_SIZE = 1
}
}

View File

@@ -1,13 +1,10 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
@@ -18,38 +15,11 @@ class AiCharacterAdminCharacterMapper(
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse {
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
return AiCharacterAdminCharacterListItemResponse(
characterId = chatCharacter.id ?: throw invalidRequest(),
name = chatCharacter.name,
description = chatCharacter.description,
imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}",
creatorMemberId = creatorMember.id ?: throw invalidRequest(),
creatorNickname = creatorMember.nickname,
originalWorkId = chatCharacter.originalWork?.id,
externalCharacterId = chatCharacter.characterUUID,
isActive = chatCharacter.isActive,
createdAtUtc = chatCharacter.createdAt?.toUtcIso()
)
return ChatCharacterListResponse.from(chatCharacter, imageHost)
}
fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse {
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
return AiCharacterAdminCharacterResponse(
characterId = chatCharacter.id ?: throw invalidRequest(),
name = chatCharacter.name,
description = chatCharacter.description,
imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}",
creatorMemberId = creatorMember.id ?: throw invalidRequest(),
creatorNickname = creatorMember.nickname,
creatorProfileImageUrl = "$imageHost/${creatorMember.profileImage ?: "profile/default-profile.png"}",
creatorIntroduce = creatorMember.introduce,
originalWorkId = chatCharacter.originalWork?.id,
externalCharacterId = chatCharacter.characterUUID,
isActive = chatCharacter.isActive,
createdAtUtc = chatCharacter.createdAt?.toUtcIso(),
updatedAtUtc = chatCharacter.updatedAt?.toUtcIso()
)
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
}
fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType {
@@ -61,11 +31,7 @@ class AiCharacterAdminCharacterMapper(
}
fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return hasRegularExternalChanges(request) || request.isActive == false
}
fun hasNonSoftDeleteChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return hasRegularExternalChanges(request) || hasDbOnlyChanges(request)
return hasRegularExternalChanges(request) || request.isActive != null
}
private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
@@ -106,21 +72,10 @@ class AiCharacterAdminCharacterMapper(
hobbies = request.hobbies,
values = request.values,
goals = request.goals,
relationships = request.relationships?.map(::toLegacyRequest),
personalities = request.personalities?.map { ChatCharacterPersonalityRequest(it.trait, it.description) },
backgrounds = request.backgrounds?.map { ChatCharacterBackgroundRequest(it.topic, it.description) },
memories = request.memories?.map { ChatCharacterMemoryRequest(it.title, it.content, it.emotion) }
)
}
fun toLegacyRequest(request: AiCharacterAdminCharacterRelationshipRequest): ChatCharacterRelationshipRequest {
return ChatCharacterRelationshipRequest(
personName = request.personName,
relationshipName = request.relationshipName,
description = request.description,
importance = request.importance,
relationshipType = request.relationshipType,
currentStatus = request.currentStatus
relationships = request.relationships,
personalities = request.personalities,
backgrounds = request.backgrounds,
memories = request.memories
)
}

View File

@@ -31,6 +31,7 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
@@ -43,6 +44,7 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.mock.web.MockPart
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
@@ -55,6 +57,8 @@ import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Propagation
@@ -97,11 +101,13 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
externalRequestCount.set(0)
Mockito.reset(amazonS3Client)
Mockito.reset(applicationEventPublisher)
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(java.net.URL("https", "cdn.example.com", "/image.png"))
}
@Test
@DisplayName("생성은 원작과 AI creatorMember 표시 정보를 만든")
fun shouldCreateCharacterAndReturnDetailWithAiCreatorMember() {
@DisplayName("생성은 필수 image와 레거시 전체 request를 반영하고 data null을 반환한")
fun shouldCreateCharacterFromLegacyRequestAndReturnNullData() {
val originalWork = OriginalWork(
title = "v2-original-work",
contentType = "webtoon",
@@ -113,32 +119,48 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
requestPart(
"""
{
"name":"v2-created-character",
"systemPrompt":"prompt",
"description":"created description",
"region":"KR",
"age":"21",
"gender":"여성",
"mbti":"INFP",
"speechPattern":"polite",
"speechStyle":"soft",
"appearance":"silver hair",
"region":"JP",
"originalTitle":"legacy title",
"originalLink":"original-link",
"originalWorkId":${originalWork.id},
"characterType":"Character"
"characterType":"Clone",
"tags":["tag-a","tag-b"],
"hobbies":["reading"],
"values":["honesty"],
"goals":["friendship"],
"relationships":[{
"personName":"Mina",
"relationshipName":"friend",
"description":"best friend",
"importance":10,
"relationshipType":"ALLY",
"currentStatus":"ACTIVE"
}],
"personalities":[{"trait":"kind","description":"kind description"}],
"backgrounds":[{"topic":"hometown","description":"moon city"}],
"memories":[{"title":"memory title","content":"memory content","emotion":"happy"}]
}
""".trimIndent().toByteArray(StandardCharsets.UTF_8)
"""
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.name").value("v2-created-character"))
.andExpect(jsonPath("$.data.externalCharacterId").value("external-v2-created-character"))
.andExpect(jsonPath("$.data.creatorNickname").value("v2-created-character"))
.andExpect(jsonPath("$.data.originalWorkId").value(originalWork.id))
.andExpect(jsonPath("$.data.isActive").value(true))
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
entityManager.flush()
entityManager.clear()
@@ -150,6 +172,160 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
assertEquals("v2-created-character", creatorMember.nickname)
assertEquals("created description", creatorMember.introduce)
assertEquals(originalWork.id, saved.originalWork!!.id)
assertEquals(21, saved.age)
assertEquals("여성", saved.gender)
assertEquals("INFP", saved.mbti)
assertEquals("polite", saved.speechPattern)
assertEquals("soft", saved.speechStyle)
assertEquals("silver hair", saved.appearance)
assertEquals("JP", saved.region)
assertEquals("legacy title", saved.originalTitle)
assertEquals("original-link", saved.originalLink)
assertEquals("Clone", saved.characterType.name)
assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted())
assertEquals(listOf("reading"), saved.hobbyMappings.map { it.hobby.hobby })
assertEquals(listOf("honesty"), saved.valueMappings.map { it.value.value })
assertEquals(listOf("friendship"), saved.goalMappings.map { it.goal.goal })
assertEquals(listOf("Mina"), saved.relationships.map { it.personName })
assertEquals(listOf("kind"), saved.personalities.map { it.trait })
assertEquals(listOf("hometown"), saved.backgrounds.map { it.topic })
assertEquals(listOf("memory title"), saved.memories.map { it.title })
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
}
@Test
@DisplayName("생성은 image part를 필수로 요구한다")
fun shouldRequireImageForCreate() {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(
requestPart(
"""
{
"name":"v2-missing-required-image",
"systemPrompt":"prompt",
"description":"description"
}
"""
)
)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, externalRequestCount.get())
assertNull(chatCharacterRepository.findByName("v2-missing-required-image"))
Mockito.verifyNoInteractions(amazonS3Client)
}
@Test
@DisplayName("생성은 빈 필수 image를 외부 API 호출 전에 거부한다")
fun shouldRejectEmptyCreateImageBeforeSideEffects() {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(MockMultipartFile("image", "empty.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf()))
.file(
requestPart(
"""
{
"name":"v2-empty-required-image",
"systemPrompt":"prompt",
"description":"description"
}
"""
)
)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, externalRequestCount.get())
assertNull(chatCharacterRepository.findByName("v2-empty-required-image"))
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 관계 importance 누락을 외부 API 호출 전에 거부한다")
fun shouldRejectMissingRelationshipImportanceBeforeSideEffects() {
val characterName = "v2-missing-relationship-importance"
val originalWork = createOriginalWork("$characterName-original-work")
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
{
"name":"$characterName",
"systemPrompt":"prompt",
"description":"description",
"originalWorkId":${originalWork.id},
"relationships":[{
"personName":"Mina",
"relationshipName":"friend",
"description":"best friend",
"relationshipType":"ALLY",
"currentStatus":"ACTIVE"
}]
}
"""
)
)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
assertNoCreatedCharacterState(characterName, originalWork.id!!)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 관계 importance null을 외부 API 호출 전에 거부한다")
fun shouldRejectNullRelationshipImportanceBeforeSideEffects() {
val characterName = "v2-null-relationship-importance"
val originalWork = createOriginalWork("$characterName-original-work")
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
{
"name":"$characterName",
"systemPrompt":"prompt",
"description":"description",
"originalWorkId":${originalWork.id},
"relationships":[{
"personName":"Mina",
"relationshipName":"friend",
"description":"best friend",
"importance":null,
"relationshipType":"ALLY",
"currentStatus":"ACTIVE"
}]
}
"""
)
)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
assertNoCreatedCharacterState(characterName, originalWork.id!!)
}
@Test
@@ -157,7 +333,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
fun shouldPublishLanguageDetectEventOnCreate() {
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val response = createFacadeForEventPayloadAssertion(publisher).create(
createFacadeForEventPayloadAssertion(publisher).create(
image = null,
requestString = """
{
@@ -171,7 +347,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
val event = eventCaptor.value as LanguageDetectEvent
assertEquals(response.characterId, event.id)
assertEquals(chatCharacterRepository.findByName("v2-language-detect-event")!!.id, event.id)
assertEquals("event description", event.query)
}
@@ -185,11 +361,12 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"{".toByteArray(StandardCharsets.UTF_8)
)
)
@@ -223,7 +400,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"{".toByteArray(StandardCharsets.UTF_8)
)
)
@@ -235,11 +412,271 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
.andExpect(jsonPath("$.message").value(message))
}
@ParameterizedTest
@CsvSource(
"POST,ko,잘못된 요청입니다.",
"POST,en,Invalid request.",
"POST,ja,無効なリクエストです。",
"PUT,ko,잘못된 요청입니다.",
"PUT,en,Invalid request.",
"PUT,ja,無効なリクエストです。"
)
@DisplayName("생성·수정은 미정의 multipart part를 지역화된 400과 부작용 없음으로 거부한다")
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(method: String, language: String, message: String) {
val characterName = "v2-undefined-part-$method-$language"
val character = if (method == "PUT") {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = characterName,
name = characterName,
description = "before description",
systemPrompt = "prompt"
)
} else {
null
}
entityManager.flush()
externalRequestCount.set(0)
val request = """
{
"name":"$characterName",
"description":"after description",
"systemPrompt":"prompt"
}
"""
val unexpectedPart = MockMultipartFile(
"unexpected",
"unexpected.txt",
MediaType.TEXT_PLAIN_VALUE,
"unexpected".toByteArray(StandardCharsets.UTF_8)
)
val result = if (method == "POST") {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(requestPart(request))
.file(unexpectedPart)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
} else {
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
.file(requestPart(request))
.file(unexpectedPart)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
}
result
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
entityManager.clear()
if (character == null) {
assertNull(chatCharacterRepository.findByName(characterName))
} else {
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
}
assertEquals(0, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@CsvSource(
"POST,ko,잘못된 요청입니다.",
"POST,en,Invalid request.",
"POST,ja,無効なリクエストです。",
"PUT,ko,잘못된 요청입니다.",
"PUT,en,Invalid request.",
"PUT,ja,無効なリクエストです。"
)
@DisplayName("생성·수정은 filename 없는 미정의 multipart part를 mutation 전 400으로 거부한다")
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(
method: String,
language: String,
message: String
) {
val characterName = "v2-filename-less-part-$method-$language"
val character = if (method == "PUT") {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = characterName,
name = characterName,
description = "before description",
systemPrompt = "prompt"
)
} else {
null
}
entityManager.flush()
externalRequestCount.set(0)
val request = """
{
"name":"$characterName",
"description":"after description",
"systemPrompt":"prompt"
}
"""
val result = if (method == "POST") {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(requestPart(request))
.part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8)))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
} else {
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
.file(requestPart(request))
.part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8)))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
}
result
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
entityManager.clear()
if (character == null) {
assertNull(chatCharacterRepository.findByName(characterName))
} else {
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
}
assertEquals(0, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@CsvSource(
value = [
"POST,text/plain,ko,잘못된 요청입니다.",
"POST,text/plain,en,Invalid request.",
"POST,text/plain,ja,無効なリクエストです。",
"POST,<missing>,ko,잘못된 요청입니다.",
"POST,<missing>,en,Invalid request.",
"POST,<missing>,ja,無効なリクエストです。",
"PUT,text/plain,ko,잘못된 요청입니다.",
"PUT,text/plain,en,Invalid request.",
"PUT,text/plain,ja,無効なリクエストです。",
"PUT,<missing>,ko,잘못된 요청입니다.",
"PUT,<missing>,en,Invalid request.",
"PUT,<missing>,ja,無効なリクエストです。"
],
nullValues = ["<missing>"]
)
@DisplayName("생성·수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
method: String,
requestContentType: String?,
language: String,
message: String
) {
val characterName = "v2-non-json-$method-${requestContentType ?: "missing"}-$language"
val character = if (method == "PUT") {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = characterName,
name = characterName,
description = "before description",
systemPrompt = "prompt"
)
} else {
null
}
val request = """
{
"name":"$characterName",
"description":"after description",
"systemPrompt":"prompt"
}
"""
val result = if (method == "POST") {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(requestPart(request, requestContentType))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
} else {
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
.file(requestPart(request, requestContentType))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
}
result
.andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
entityManager.clear()
if (character == null) {
assertNull(chatCharacterRepository.findByName(characterName))
} else {
assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description)
}
assertEquals(0, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@ValueSource(strings = ["POST", "PUT"])
@DisplayName("생성·수정은 request part 누락을 기존 400으로 유지한다")
fun shouldKeepMissingRequestPartAsBadRequest(method: String) {
val character = if (method == "PUT") {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-missing-request-part",
name = "v2-missing-request-part",
description = "before description",
systemPrompt = "prompt"
)
} else {
null
}
val result = if (method == "POST") {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.with(adminAuthentication())
)
} else {
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}")
.with(adminAuthentication())
)
}
result
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@Test
@DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다")
fun shouldRejectServerOwnedCreateFields() {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -268,6 +705,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
fun shouldRejectInvalidCharacterTypeBeforeExternalApi() {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -304,6 +742,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -330,6 +769,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
fun shouldRejectMissingOriginalWorkBeforeExternalApi() {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -362,6 +802,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -430,7 +871,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다")
@DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성과 S3 호출만 남긴다")
fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) {
val characterName = "v2-create-event-failure-$language"
val originalWork = createOriginalWork("v2-create-event-original-work-$language")
@@ -442,6 +883,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
try {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -466,7 +908,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
assertEquals(1, externalRequestCount.get())
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java))
Mockito.verifyNoInteractions(amazonS3Client)
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
assertNoCreatedCharacterState(characterName, originalWork.id!!)
}
@@ -487,6 +929,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
mockMvc.perform(
multipart("/api/v2/admin/ai-characters")
.file(createImagePart())
.file(
requestPart(
"""
@@ -547,12 +990,64 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
}
@Test
@DisplayName("수정의 isActive false는 다른 변경이나 이미지를 함께 받지 않는")
fun shouldRejectMixedSoftDeleteBeforeExternalAndImageSideEffects() {
@DisplayName("수정의 isActive false는 다른 optional JSON 필드를 허용하고 비활성화만 반영한")
fun shouldIgnoreOtherFieldsInMixedSoftDeleteRequest() {
val originalWork = createOriginalWork("v2-mixed-soft-delete-original")
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-mixed-soft-delete",
name = "v2-mixed-soft-delete",
description = "before description",
systemPrompt = "before prompt",
tags = listOf("before-tag")
)
originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!)
entityManager.flush()
externalRequestCount.set(0)
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
.file(
requestPart(
"""
{
"isActive":false,
"systemPrompt":"ignored prompt",
"description":"ignored description",
"age":"99",
"characterType":"Clone",
"originalWorkId":999999,
"tags":["ignored-tag"]
}
""".trimIndent()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
assertEquals(1, externalRequestCount.get())
assertFalse(saved.isActive)
assertEquals("before description", saved.description)
assertEquals("before prompt", saved.systemPrompt)
assertNull(saved.age)
assertEquals("Character", saved.characterType.name)
assertEquals(originalWork.id, saved.originalWork!!.id)
assertEquals(listOf("before-tag"), saved.tagMappings.map { it.tag.tag })
Mockito.verifyNoInteractions(amazonS3Client)
}
@Test
@DisplayName("수정의 isActive true 단독 요청은 레거시처럼 유효한 no-op mutation이다")
fun shouldAcceptIsActiveTrueOnlyUpdateAsLegacyNoOp() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-is-active-true-only",
name = "v2-is-active-true-only",
description = "before description",
systemPrompt = "prompt"
)
entityManager.flush()
@@ -560,24 +1055,18 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
.file(
requestPart(
"""
{
"isActive":false,
"description":"ignored description"
}
""".trimIndent()
)
)
.file(requestPart("""{"isActive":true}"""))
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(status().isOk)
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
assertEquals(0, externalRequestCount.get())
assertTrue(chatCharacterRepository.findById(character.id!!).orElseThrow().isActive)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
assertTrue(saved.isActive)
assertEquals("before description", saved.description)
assertEquals(1, externalRequestCount.get())
Mockito.verifyNoInteractions(amazonS3Client)
}
@@ -659,6 +1148,7 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
entityManager.clear()
assertEquals(
@@ -669,20 +1159,21 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
}
@Test
@DisplayName("수정은 이미지와 표시 정보를 동기화하고 flush된 updatedAtUtc를 반환한다")
fun shouldUpdateCharacterImageAndCreatorDisplayFieldsWithFlushedTimestamp() {
@DisplayName("수정은 레거시 optional 필드를 반영하고 data null을 반환한다")
fun shouldUpdateCharacterFromLegacyOptionalFieldsAndReturnNullData() {
val originalWork = createOriginalWork("v2-update-original-work")
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-character-update",
name = "v2-character-update",
description = "before description",
systemPrompt = "prompt"
systemPrompt = "before prompt",
tags = listOf("before-tag")
)
entityManager.flush()
val beforeUpdatedAt = character.updatedAt!!
Thread.sleep(5)
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(java.net.URL("https://cdn.example.com/characters/updated.png"))
val updateResponse = mockMvc.perform(
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}")
.file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)))
.file(
@@ -690,7 +1181,33 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
"""
{
"name":"v2-character-updated",
"description":"after description"
"systemPrompt":"after prompt",
"description":"after description",
"age":"29",
"gender":"여성",
"mbti":"ENTJ",
"speechPattern":"direct",
"speechStyle":"formal",
"appearance":"black hair",
"originalTitle":"updated title",
"originalLink":"updated-link",
"originalWorkId":${originalWork.id},
"characterType":"Clone",
"tags":["after-tag"],
"hobbies":["running"],
"values":["courage"],
"goals":["success"],
"relationships":[{
"personName":"Jin",
"relationshipName":"rival",
"description":"friendly rival",
"importance":7,
"relationshipType":"RIVAL",
"currentStatus":"ACTIVE"
}],
"personalities":[{"trait":"bold","description":"bold description"}],
"backgrounds":[{"topic":"school","description":"academy"}],
"memories":[{"title":"victory","content":"first win","emotion":"proud"}]
}
""".trimIndent()
)
@@ -698,31 +1215,34 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.name").value("v2-character-updated"))
.andExpect(jsonPath("$.data.creatorNickname").value("v2-character-updated"))
.andExpect(jsonPath("$.data.creatorIntroduce").value("after description"))
.andReturn()
val updateTimestamp = objectMapper.readTree(updateResponse.response.contentAsString)
.path("data")
.path("updatedAtUtc")
.asText()
val detailResponse = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andReturn()
val detailTimestamp = objectMapper.readTree(detailResponse.response.contentAsString)
.path("data")
.path("updatedAtUtc")
.asText()
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow()
assertEquals(detailTimestamp, updateTimestamp)
assertEquals("v2-character-updated", saved.name)
assertEquals("after prompt", saved.systemPrompt)
assertEquals("after description", saved.description)
assertEquals(29, saved.age)
assertEquals("여성", saved.gender)
assertEquals("ENTJ", saved.mbti)
assertEquals("direct", saved.speechPattern)
assertEquals("formal", saved.speechStyle)
assertEquals("black hair", saved.appearance)
assertEquals("updated title", saved.originalTitle)
assertEquals("updated-link", saved.originalLink)
assertEquals(originalWork.id, saved.originalWork!!.id)
assertEquals("Clone", saved.characterType.name)
assertEquals(listOf("after-tag"), saved.tagMappings.map { it.tag.tag })
assertEquals(listOf("running"), saved.hobbyMappings.map { it.hobby.hobby })
assertEquals(listOf("courage"), saved.valueMappings.map { it.value.value })
assertEquals(listOf("success"), saved.goalMappings.map { it.goal.goal })
assertEquals(listOf("Jin"), saved.relationships.map { it.personName })
assertEquals(listOf("bold"), saved.personalities.map { it.trait })
assertEquals(listOf("school"), saved.backgrounds.map { it.topic })
assertEquals(listOf("victory"), saved.memories.map { it.title })
assertTrue(saved.updatedAt!!.isAfter(beforeUpdatedAt))
assertEquals(saved.imagePath, creatorMember.profileImage)
assertEquals(saved.name, creatorMember.nickname)
assertEquals(saved.description, creatorMember.introduce)
@@ -872,14 +1392,14 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"isActive":false}""".toByteArray(StandardCharsets.UTF_8)
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.isActive").value(false))
.andExpect(content().json(NULL_SUCCESS_RESPONSE, true))
entityManager.flush()
entityManager.clear()
@@ -907,15 +1427,19 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
)
)
private fun requestPart(request: String): MockMultipartFile {
private fun requestPart(request: String, contentType: String? = MediaType.APPLICATION_JSON_VALUE): MockMultipartFile {
return MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
contentType,
request.trimIndent().toByteArray(StandardCharsets.UTF_8)
)
}
private fun createImagePart(): MockMultipartFile {
return MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))
}
private fun saveAudioContent(creator: Member): AudioContent {
val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true)
entityManager.persist(theme)
@@ -1050,7 +1574,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
characterMapper = characterMapper,
originalWorkService = originalWorkService,
creatorMemberService = creatorMemberService,
applicationEventPublisher = publisher
applicationEventPublisher = publisher,
imageHost = "https://cdn.example.com"
)
}
@@ -1081,6 +1606,8 @@ class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor(
)
companion object {
private const val NULL_SUCCESS_RESPONSE =
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
private val externalRequestCount = AtomicInteger()
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/api/characters") { exchange ->

View File

@@ -1,14 +1,18 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.databind.ObjectMapper
import com.sun.net.httpserver.HttpServer
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.endsWith
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
@@ -46,84 +50,215 @@ import javax.persistence.EntityManager
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager
) {
@Test
@DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다")
fun shouldNormalizeListPageAndMinimumSize() {
chatCharacterService.createChatCharacterWithDetails(
@DisplayName("목록은 레거시 totalCount와 content의 exact JSON 필드를 반환한다")
fun shouldReturnLegacyListContract() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-list-character",
name = "v2-list-character",
description = "description",
systemPrompt = "prompt"
systemPrompt = "prompt",
age = 21,
gender = "여성",
mbti = "INFP",
speechPattern = "polite",
speechStyle = "soft",
tags = listOf("list-tag")
)
entityManager.flush()
mockMvc.perform(
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "-1")
.param("page", "0")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].name").value("v2-list-character"))
.andExpect(jsonPath("$.data.items[0].creatorProfileImageUrl").doesNotExist())
.andExpect(jsonPath("$.data.items[0].creatorIntroduce").doesNotExist())
.andExpect(jsonPath("$.data.items[0].updatedAtUtc").doesNotExist())
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
val item = data.path("content").path(0)
assertEquals(setOf("totalCount", "content"), data.fieldNames().asSequence().toSet())
assertEquals(
setOf(
"id",
"name",
"imageUrl",
"description",
"gender",
"age",
"mbti",
"speechStyle",
"speechPattern",
"region",
"tags",
"createdAt",
"updatedAt"
),
item.fieldNames().asSequence().toSet()
)
assertEquals(character.id, item.path("id").asLong())
assertEquals("v2-list-character", item.path("name").asText())
assertEquals(listOf("list-tag"), item.path("tags").map { it.asText() })
}
@Test
@DisplayName("검색은 최대 size를 50으로 보정한다")
fun shouldNormalizeSearchMaximumSize() {
@DisplayName("목록은 OpenAPI minimum인 size 1을 그대로 적용한다")
fun shouldHonorLegacyMinimumListSize() {
repeat(2) { index ->
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-list-size-$index",
name = "v2-list-size-$index",
description = "description",
systemPrompt = "prompt"
)
}
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(2))
.andExpect(jsonPath("$.data.content.length()").value(1))
}
@Test
@DisplayName("검색은 레거시 searchTerm query를 사용한다")
fun shouldSearchByLegacySearchTerm() {
val matchingCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-search-character",
name = "v2-search-character",
description = "find this character",
systemPrompt = "prompt"
)
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-non-matching-character",
name = "v2-non-matching-character",
description = "different description",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("search", "find this")
.param("searchTerm", "find this")
.param("size", "100")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].externalCharacterId").value("v2-search-character"))
.andExpect(jsonPath("$.data.content[0].id").value(matchingCharacter.id))
.andExpect(jsonPath("$.data.content[0].name").value("v2-search-character"))
}
@Test
@DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다")
fun shouldReturnDetailForAiCharacter() {
@DisplayName("상세는 레거시 전체 필드와 nested 배열을 exact JSON으로 반환한다")
fun shouldReturnLegacyDetailContract() {
val originalWork = OriginalWork(
title = "detail original work",
contentType = "webtoon",
category = "fantasy",
isAdult = false,
description = "original work description"
)
entityManager.persist(originalWork)
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-detail-character",
name = "v2-detail-character",
description = "detail description",
systemPrompt = "prompt"
systemPrompt = "detail prompt",
age = 22,
gender = "여성",
mbti = "ENFP",
speechPattern = "bright",
speechStyle = "friendly",
appearance = "silver hair",
region = "JP",
tags = listOf("detail-tag"),
hobbies = listOf("reading"),
values = listOf("honesty"),
goals = listOf("friendship"),
memories = listOf(Triple("memory title", "memory content", "happy")),
personalities = listOf(Pair("kind", "kind description")),
backgrounds = listOf(Pair("hometown", "moon city")),
relationships = listOf(
ChatCharacterRelationshipRequest(
personName = "Mina",
relationshipName = "friend",
description = "best friend",
importance = 10,
relationshipType = "ALLY",
currentStatus = "ACTIVE"
)
)
)
character.imagePath = "characters/${character.id}/detail.png"
character.originalWork = originalWork
entityManager.flush()
mockMvc.perform(
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.characterId").value(character.id))
.andExpect(jsonPath("$.data.externalCharacterId").value("v2-detail-character"))
.andExpect(jsonPath("$.data.creatorMemberId").value(character.creatorMember!!.id))
.andExpect(jsonPath("$.data.creatorNickname").value("v2-detail-character"))
.andExpect(jsonPath("$.data.creatorProfileImageUrl").value(endsWith("/profile/default-profile.png")))
.andExpect(jsonPath("$.data.creatorIntroduce").value("detail description"))
.andExpect(jsonPath("$.data.isActive").value(true))
.andExpect(jsonPath("$.data.createdAtUtc").value(endsWith("Z")))
.andExpect(jsonPath("$.data.updatedAtUtc").value(endsWith("Z")))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
assertEquals(
setOf(
"id",
"characterUUID",
"name",
"imageUrl",
"description",
"systemPrompt",
"characterType",
"age",
"gender",
"mbti",
"speechPattern",
"speechStyle",
"appearance",
"region",
"isActive",
"tags",
"hobbies",
"values",
"goals",
"relationships",
"personalities",
"backgrounds",
"memories",
"originalWork"
),
data.fieldNames().asSequence().toSet()
)
assertEquals(character.id, data.path("id").asLong())
assertEquals("v2-detail-character", data.path("characterUUID").asText())
assertEquals("detail prompt", data.path("systemPrompt").asText())
assertEquals("Character", data.path("characterType").asText())
assertEquals(listOf("detail-tag"), data.path("tags").map { it.asText() })
assertEquals(
setOf("personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"),
data.path("relationships").path(0).fieldNames().asSequence().toSet()
)
assertEquals("Mina", data.path("relationships").path(0).path("personName").asText())
assertEquals(setOf("trait", "description"), data.path("personalities").path(0).fieldNames().asSequence().toSet())
assertEquals(setOf("topic", "description"), data.path("backgrounds").path(0).fieldNames().asSequence().toSet())
assertEquals(setOf("title", "content", "emotion"), data.path("memories").path(0).fieldNames().asSequence().toSet())
assertEquals(setOf("id", "imageUrl", "title"), data.path("originalWork").fieldNames().asSequence().toSet())
assertEquals(originalWork.id, data.path("originalWork").path("id").asLong())
assertTrue(data.path("imageUrl").asText().endsWith("characters/${character.id}/detail.png"))
}
@ParameterizedTest

View File

@@ -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"
}
}