diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt index 7ee5536c..665042aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt @@ -7,8 +7,19 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri import kr.co.vividnext.sodalive.creator.admin.content.series.content.QSearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType -interface CreatorAdminContentSeriesRepository : JpaRepository, CreatorAdminContentSeriesQueryRepository +interface CreatorAdminContentSeriesRepository : JpaRepository, CreatorAdminContentSeriesQueryRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from Series s where s.member.id = :creatorId and s.id in :ids and s.isActive = true order by s.id asc") + fun findActiveByCreatorIdAndIdInForUpdate( + @Param("creatorId") creatorId: Long, + @Param("ids") ids: List + ): List +} interface CreatorAdminContentSeriesQueryRepository { fun findByIdAndCreatorId(id: Long, creatorId: Long): Series? diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt new file mode 100644 index 00000000..73251bfd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt @@ -0,0 +1,133 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +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.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +@RestController +@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/series") +class AiCharacterAdminSeriesController( + private val facade: AiCharacterAdminSeriesFacade +) { + @GetMapping + fun list( + @PathVariable characterId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, page, size)) + } + + @GetMapping("/{seriesId:[0-9]+}") + fun detail( + @PathVariable characterId: Long, + @PathVariable seriesId: Long + ): ApiResponse { + return ApiResponse.ok(facade.detail(characterId, seriesId)) + } + + @GetMapping("/{seriesId:[0-9]+}/contents") + fun contents( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = ApiResponse.ok(facade.contents(characterId, seriesId, page, size)) + + @GetMapping("/{seriesId:[0-9]+}/contents/search") + fun searchContents( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestParam("search_word") searchWord: String + ) = ApiResponse.ok(facade.searchContents(characterId, seriesId, searchWord)) + + @PostMapping("/{seriesId:[0-9]+}/contents", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun addContents( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestBody request: String + ): ApiResponse { + facade.addContents(characterId, seriesId, request) + return ApiResponse.ok(null) + } + + @DeleteMapping("/{seriesId:[0-9]+}/contents/{contentId:[0-9]+}") + fun removeContent( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @PathVariable contentId: Long + ): ApiResponse { + facade.removeContent(characterId, seriesId, contentId) + return ApiResponse.ok(null) + } + + @PutMapping("/orders", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun updateOrders( + @PathVariable characterId: Long, + @RequestBody request: String + ): ApiResponse { + facade.updateOrders(characterId, request) + return ApiResponse.ok(null) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @PathVariable characterId: Long, + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.create(characterId, image, request) + return ApiResponse.ok(null) + } + + @PutMapping("/{seriesId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun update( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestPart(value = "image", required = false) image: MultipartFile?, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, seriesId, image, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) { + if (multipartRequest.fileMap.keys.any { it !in SERIES_MULTIPART_PARTS } || + multipartRequest.parts.any { it.name !in SERIES_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 SERIES_MULTIPART_PARTS = setOf("image", "request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt new file mode 100644 index 00000000..3803997b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListResponse +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState + +typealias AiCharacterAdminSeriesListResponse = GetCreatorAdminContentSeriesListResponse +typealias AiCharacterAdminSeriesListItem = GetCreatorAdminContentSeriesListItem +typealias AiCharacterAdminSeriesDetailResponse = GetCreatorAdminContentSeriesListItem +typealias AiCharacterAdminSeriesCreateRequest = CreateSeriesRequest + +data class AiCharacterAdminSeriesUpdateRequest( + val title: String? = null, + val introduction: String? = null, + val publishedDaysOfWeek: Set? = null, + val genreId: Long? = null, + val isAdult: Boolean? = null, + val state: SeriesState? = null, + val isActive: Boolean? = null, + val writer: String? = null, + val studio: String? = null +) + +data class AiCharacterAdminSeriesContentAddRequest( + val contentIdList: List? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt new file mode 100644 index 00000000..69fbd5ef --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt @@ -0,0 +1,220 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreService +import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesContentResponse +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem +import kr.co.vividnext.sodalive.creator.admin.content.series.ModifySeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.UpdateOrdersRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +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.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +@Service +class AiCharacterAdminSeriesFacade( + private val objectMapper: ObjectMapper, + private val targetResolver: AiCharacterAdminTargetResolver, + private val repository: AiCharacterAdminSeriesRepository, + private val legacyService: CreatorAdminContentSeriesService, + private val genreService: AdminContentSeriesGenreService, + @Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String +) { + @Transactional(readOnly = true) + fun genres(): List { + return genreService.getSeriesGenreList() + } + + @Transactional(readOnly = true) + fun list(characterId: Long, page: Int, size: Int): AiCharacterAdminSeriesListResponse { + val target = resolveActiveTarget(characterId) + if (page < 0 || size < 1) throw invalidRequest() + + val pageable = PageRequest.of(page, size) + return legacyService.getSeriesList( + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + creatorId = target.creatorMember.id ?: throw invalidRequest() + ) + } + + @Transactional(readOnly = true) + fun detail(characterId: Long, seriesId: Long): AiCharacterAdminSeriesDetailResponse { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) + ?: throw invalidRequest() + return GetCreatorAdminContentSeriesListItem( + seriesId = series.id!!, + title = series.title, + introduction = series.introduction, + coverImageUrl = "$coverImageHost/${series.coverImage!!}", + publishedDaysOfWeek = series.publishedDaysOfWeek.toList(), + genreId = series.genre!!.id!!, + isAdult = series.isAdult, + state = series.state, + isActive = series.isActive, + writer = series.writer, + studio = series.studio + ) + } + + @Transactional(readOnly = true) + fun contents( + characterId: Long, + seriesId: Long, + page: Int, + size: Int + ): GetCreatorAdminContentSeriesContentResponse { + val target = resolveOwnedActiveSeries(characterId, seriesId) + if (page < 0 || size < 1) throw invalidRequest() + + val pageable = PageRequest.of(page, size) + return legacyService.getSeriesContent( + seriesId = seriesId, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + creatorId = target.creatorMember.id ?: throw invalidRequest() + ) + } + + @Transactional(readOnly = true) + fun searchContents(characterId: Long, seriesId: Long, searchWord: String): List { + val target = resolveOwnedActiveSeries(characterId, seriesId) + return legacyService.searchContentNotInSeries( + seriesId = seriesId, + searchWord = searchWord, + memberId = target.creatorMember.id ?: throw invalidRequest() + ) + } + + @Transactional + fun addContents(characterId: Long, seriesId: Long, requestString: String) { + val request = readRequest(requestString, AiCharacterAdminSeriesContentAddRequest::class.java) + val target = resolveOwnedActiveSeries(characterId, seriesId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val contentIds = request.contentIdList ?: throw invalidRequest() + if (contentIds.isEmpty()) { + legacyService.addingContentToTheSeries( + AddingContentToTheSeriesRequest(seriesId, contentIds), + creatorMemberId + ) + return + } + if (contentIds.size != contentIds.toSet().size) throw invalidRequest() + + val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest() + val linkedContentIds = series.contentList.mapNotNull { it.content?.id }.toSet() + contentIds.forEach { contentId -> + if (contentId in linkedContentIds || + repository.findEligibleContentByIdAndCreatorMemberId(contentId, creatorMemberId) == null + ) { + throw invalidRequest() + } + } + legacyService.addingContentToTheSeries(AddingContentToTheSeriesRequest(seriesId, contentIds), creatorMemberId) + } + + @Transactional + fun removeContent(characterId: Long, seriesId: Long, contentId: Long?) { + val target = resolveOwnedActiveSeries(characterId, seriesId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val resolvedContentId = contentId ?: throw invalidRequest() + val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest() + if (series.contentList.none { it.content?.id == resolvedContentId && it.content?.member?.id == creatorMemberId }) { + throw invalidRequest() + } + legacyService.removeContentInTheSeries(RemoveContentToTheSeriesRequest(seriesId, resolvedContentId), creatorMemberId) + } + + @Transactional + fun updateOrders(characterId: Long, requestString: String) { + val request = readRequest(requestString, UpdateOrdersRequest::class.java) + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val ids = request.ids + if (ids.isEmpty() || ids.size != ids.toSet().size) throw invalidRequest() + val lockedIds = repository.findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId, ids).map { it.id }.toSet() + if (lockedIds != ids.toSet()) throw invalidRequest() + legacyService.updateSeriesOrders(ids) + } + + @Transactional + fun create(characterId: Long, image: MultipartFile, requestString: String) { + val target = resolveActiveTarget(characterId) + if (image.isEmpty) throw invalidRequest() + val request = readRequest(requestString, AiCharacterAdminSeriesCreateRequest::class.java) + rejectMissingActiveGenre(request.genreId) + legacyService.createSeries(image, requestString, target.creatorMember) + } + + @Transactional + fun update(characterId: Long, seriesId: Long, image: MultipartFile?, requestString: String) { + val target = resolveOwnedActiveSeries(characterId, seriesId) + val normalizedImage = image?.takeUnless { it.isEmpty } + val request = readRequest(requestString, AiCharacterAdminSeriesUpdateRequest::class.java) + request.genreId?.let(::rejectMissingActiveGenre) + legacyService.modifySeries( + coverImage = normalizedImage, + requestString = objectMapper.writeValueAsString( + ModifySeriesRequest( + seriesId = seriesId, + title = request.title, + introduction = request.introduction, + publishedDaysOfWeek = request.publishedDaysOfWeek, + genreId = request.genreId, + isAdult = request.isAdult, + state = request.state, + isActive = request.isActive, + writer = request.writer, + studio = request.studio + ) + ), + member = target.creatorMember + ) + } + + private fun resolveOwnedActiveSeries(characterId: Long, seriesId: Long): AiCharacterAdminTarget { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest() + return target + } + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + return target + } + + private fun rejectMissingActiveGenre(genreId: Long) { + if (genreId <= 0 || !repository.existsActiveGenre(genreId)) throw invalidRequest() + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt new file mode 100644 index 00000000..1822158d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin/ai-characters") +class AiCharacterAdminSeriesReferenceController( + private val facade: AiCharacterAdminSeriesFacade +) { + @GetMapping("/series-genres") + fun genres(): ApiResponse> { + return ApiResponse.ok(facade.genres()) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt new file mode 100644 index 00000000..b8cd8c05 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository +import org.springframework.stereotype.Repository + +@Repository +class AiCharacterAdminSeriesRepository( + private val repository: CreatorAdminContentSeriesRepository, + private val genreRepository: CreatorAdminContentSeriesGenreRepository, + private val audioContentRepository: AudioContentRepository +) { + fun findActiveByIdAndCreatorMemberId(seriesId: Long, creatorMemberId: Long): Series? { + return repository.findByIdAndCreatorId(seriesId, creatorMemberId)?.takeIf { it.isActive } + } + + fun findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId: Long, ids: List): List { + return repository.findActiveByCreatorIdAndIdInForUpdate(creatorMemberId, ids.sorted()) + } + + fun existsActiveGenre(genreId: Long): Boolean { + return genreRepository.findById(genreId).orElse(null)?.isActive == true + } + + fun findEligibleContentByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? { + return audioContentRepository.findByIdAndCreatorId(contentId, creatorMemberId) + ?.takeIf { it.duration != null && (it.isActive || it.releaseDate != null) } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt new file mode 100644 index 00000000..2df32ecb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt @@ -0,0 +1,424 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +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.hamcrest.Matchers.nullValue +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.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.MediaType +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.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesContentTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("연결 콘텐츠 목록은 legacy 응답 형태와 owner 전체 link 수를 반환한다") + fun shouldReturnLinkedContentsWithLegacyResponseShape() { + val character = createCharacter("series-content-list-character") + val owner = character.creatorMember!! + val genre = saveGenre("series-content-list-genre") + val targetSeries = saveSeries(owner, genre, "target series") + val anotherSeries = saveSeries(owner, genre, "another series") + val targetContent = saveAudioContent(owner, "target night") + val anotherContent = saveAudioContent(owner, "another night") + saveSeriesContent(targetSeries, targetContent) + saveSeriesContent(anotherSeries, anotherContent) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${targetSeries.id}/contents") + .param("page", "0") + .param("size", "20") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + val item = data.path("items").single() + assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet()) + assertEquals(2, data.path("totalCount").asInt()) + assertEquals(targetContent.id, item.path("contentId").asLong()) + assertEquals("target night", item.path("title").asText()) + assertEquals("https://test.cloudfront.net/audio/target-night.png", item.path("coverImage").asText()) + assertEquals(setOf("contentId", "coverImage", "title", "isAdult"), item.fieldNames().asSequence().toSet()) + } + + @Test + @DisplayName("미연결 콘텐츠 검색은 eligible owner 콘텐츠만 별도 배열 응답으로 반환한다") + fun shouldSearchEligibleUnlinkedOwnerContentsWithSeparateResponse() { + val character = createCharacter("series-content-search-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-content-search-other-character") + val genre = saveGenre("series-content-search-genre") + val series = saveSeries(owner, genre, "search series") + val linked = saveAudioContent(owner, "night linked") + val eligible = saveAudioContent(owner, "night eligible") + val reserved = saveAudioContent(owner, "night reserved", isActive = false) + saveAudioContent(owner, "night processing", duration = null) + saveAudioContent(owner, "night inactive", isActive = false, releaseDate = null) + saveAudioContent(otherCharacter.creatorMember!!, "night foreign") + saveSeriesContent(series, linked) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/search") + .param("search_word", "night") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val items = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + assertEquals(setOf(eligible.id, reserved.id), items.map { it.path("contentId").asLong() }.toSet()) + assertTrue(items.all { it.fieldNames().asSequence().toSet() == setOf("contentId", "title", "coverImage") }) + assertTrue(items.all { it.path("coverImage").asText().startsWith("https://test.cloudfront.net/audio/") }) + } + + @Test + @DisplayName("연결 목록은 pagination 경계를 적용하고 잘못된 pagination을 거부한다") + fun shouldApplyPaginationAndRejectInvalidPagination() { + val character = createCharacter("series-content-pagination-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-pagination-genre"), "pagination series") + saveSeriesContent(series, saveAudioContent(owner, "pagination first")) + saveSeriesContent(series, saveAudioContent(owner, "pagination second")) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .param("page", "1") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + + listOf("-1" to "20", "0" to "0").forEach { (page, size) -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .param("page", page) + .param("size", size) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + } + + @Test + @DisplayName("소유한 eligible 콘텐츠는 모두 연결하고 성공 응답은 data null이다") + fun shouldLinkAllOwnedEligibleContentsAtomically() { + val character = createCharacter("series-content-link-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-link-genre"), "link series") + val first = saveAudioContent(owner, "link first") + val second = saveAudioContent(owner, "link second") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[${first.id},${second.id}]}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(setOf(first.id, second.id), contentIdsInSeries(series.id!!).toSet()) + } + + @Test + @DisplayName("콘텐츠 연결 body에 계약 밖 field가 있으면 400으로 거부하고 연결하지 않는다") + fun shouldRejectContentLinkUnknownFieldWithoutMutation() { + val character = createCharacter("series-content-unknown-field-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-unknown-field-genre"), "unknown field series") + val content = saveAudioContent(owner, "unknown field content") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[${content.id}],"unexpected":true}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertTrue(contentIdsInSeries(series.id!!).isEmpty()) + } + + @Test + @DisplayName("잘못된 콘텐츠 ID와 이미 연결된 콘텐츠는 일부 연결 없이 거부하고 빈 목록은 legacy 오류를 유지한다") + fun shouldRejectInvalidOrLinkedContentBeforeAnyLinkMutation() { + val character = createCharacter("series-content-invalid-link-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-content-invalid-link-other-character") + val series = saveSeries(owner, saveGenre("series-content-invalid-link-genre"), "invalid link series") + val valid = saveAudioContent(owner, "valid link") + val foreign = saveAudioContent(otherCharacter.creatorMember!!, "foreign link") + val inactive = saveAudioContent(owner, "inactive link", isActive = false, releaseDate = null) + val linked = saveAudioContent(owner, "already linked") + saveSeriesContent(series, linked) + entityManager.flush() + entityManager.clear() + + listOf( + listOf(valid.id, foreign.id), + listOf(valid.id, Long.MAX_VALUE), + listOf(valid.id, inactive.id), + listOf(valid.id, valid.id), + listOf(linked.id) + ).forEach { contentIdList -> + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":$contentIdList}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertEquals(setOf(linked.id), contentIdsInSeries(series.id!!).toSet()) + } + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[]}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.message").value("No content was added.")) + } + + @Test + @DisplayName("연결 해제는 기존 소유 link만 제거하고 성공 응답은 data null이다") + fun shouldUnlinkExistingOwnerContentAndRejectMissingLink() { + val character = createCharacter("series-content-unlink-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-unlink-genre"), "unlink series") + val content = saveAudioContent(owner, "unlink content") + saveSeriesContent(series, content) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(contentIdsInSeries(series.id!!).isEmpty()) + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + + @Test + @DisplayName("연결 해제는 soft delete된 기존 소유 link도 제거한다") + fun shouldUnlinkSoftDeletedOwnerContent() { + val character = createCharacter("series-content-unlink-soft-deleted-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-unlink-soft-deleted-genre"), "soft deleted unlink") + val content = saveAudioContent(owner, "soft deleted unlink content") + saveSeriesContent(series, content) + content.isActive = false + content.releaseDate = null + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(contentIdsInSeries(series.id!!).isEmpty()) + } + + @Test + @DisplayName("비활성 또는 타 소유 시리즈와 malformed 연결 요청은 400으로 거부한다") + fun shouldRejectInvalidSeriesAndMalformedContentRequests() { + val character = createCharacter("series-content-invalid-series-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-content-invalid-series-other-character") + val genre = saveGenre("series-content-invalid-series-genre") + val inactive = saveSeries(owner, genre, "inactive series", isActive = false) + val foreign = saveSeries(otherCharacter.creatorMember!!, genre, "foreign series") + val content = saveAudioContent(owner, "malformed content") + entityManager.flush() + entityManager.clear() + + listOf(inactive.id!!, foreign.id!!, Long.MAX_VALUE).forEach { seriesId -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/$seriesId/contents/search") + .param("search_word", "content") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${foreign.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("{") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${foreign.id}/contents/${content.id}") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + + private fun ResultActions.andExpectInvalidRequest(): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun saveSeries(owner: Member, genre: SeriesGenre, title: String, isActive: Boolean = true): Series { + return Series( + title = title, + introduction = "introduction for $title", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun saveAudioContent( + owner: Member, + title: String, + isActive: Boolean = true, + releaseDate: LocalDateTime? = LocalDateTime.of(2026, 7, 24, 10, 0), + duration: String? = "00:10:00" + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5, + releaseDate = releaseDate + ).apply { + member = owner + this.theme = theme + this.isActive = isActive + this.duration = duration + content = "audio/${title.replace(" ", "-")}.mp3" + coverImage = "audio/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + return SeriesContent().apply { + this.series = series + this.content = content + entityManager.persist(this) + } + } + + private fun contentIdsInSeries(seriesId: Long): List { + return entityManager.createQuery( + "select seriesContent.content.id from SeriesContent seriesContent where seriesContent.series.id = :seriesId", + java.lang.Long::class.java + ) + .setParameter("seriesId", seriesId) + .resultList + .map(java.lang.Long::toLong) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt new file mode 100644 index 00000000..d4184bd0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt @@ -0,0 +1,284 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.amazonaws.services.s3.AmazonS3Client +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +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.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +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.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @Autowired + private lateinit var legacySeriesService: CreatorAdminContentSeriesService + + private lateinit var originalPublisher: ApplicationEventPublisher + + @BeforeEach + fun setUp() { + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/series-cover")) + originalPublisher = replacePublisher(applicationEventPublisher) + } + + @AfterEach + fun tearDown() { + replacePublisher(originalPublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("target invalid request는 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForTarget(language: String, message: String) { + val target = createCharacter("series-contract-target-$language") + target.isActive = false + entityManager.flush() + entityManager.clear() + + listOf( + get("/api/v2/admin/ai-characters/${target.id}/series"), + get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("series invalid request는 active target에서도 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForSeries(language: String, message: String) { + val target = createCharacter("series-contract-series-target-$language") + val otherTarget = createCharacter("series-contract-series-other-$language") + val genre = saveGenre("series-contract-series-genre-$language") + val inactiveSeries = saveSeries(target.creatorMember!!, genre, "inactive detail", isActive = false) + val foreignSeries = saveSeries(otherTarget.creatorMember!!, genre, "foreign detail") + entityManager.flush() + entityManager.clear() + + listOf( + get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}"), + get("/api/v2/admin/ai-characters/${target.id}/series/${inactiveSeries.id}"), + get("/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("content/pagination invalid request는 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForContentAndPagination(language: String, message: String) { + val target = createCharacter("series-contract-content-target-$language") + val otherTarget = createCharacter("series-contract-content-other-$language") + val genre = saveGenre("series-contract-content-genre-$language") + val series = saveSeries(target.creatorMember!!, genre, "content series") + val inactiveSeries = saveSeries(target.creatorMember!!, genre, "inactive content series", isActive = false) + val foreignSeries = saveSeries(otherTarget.creatorMember!!, genre, "foreign content series") + entityManager.flush() + entityManager.clear() + + listOf( + get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}/contents/search") + .param("search_word", "night"), + get("/api/v2/admin/ai-characters/${target.id}/series/${series.id}/contents") + .param("page", "-1") + .param("size", "20"), + get("/api/v2/admin/ai-characters/${target.id}/series/${series.id}/contents") + .param("page", "0") + .param("size", "0"), + get("/api/v2/admin/ai-characters/${target.id}/series/${inactiveSeries.id}/contents/search") + .param("search_word", "night"), + get("/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}/contents/search") + .param("search_word", "night") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("order/binding invalid request는 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForOrderAndBinding(language: String, message: String) { + val target = createCharacter("series-contract-order-target-$language") + val otherTarget = createCharacter("series-contract-order-other-$language") + val genre = saveGenre("series-contract-order-genre-$language") + val owned = saveSeries(target.creatorMember!!, genre, "owned order") + val inactive = saveSeries(target.creatorMember!!, genre, "inactive order", isActive = false) + val foreign = saveSeries(otherTarget.creatorMember!!, genre, "foreign order") + entityManager.flush() + entityManager.clear() + + listOf( + put("/api/v2/admin/ai-characters/${target.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("{"), + put("/api/v2/admin/ai-characters/${target.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${owned.id},${foreign.id},${inactive.id}]}""") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${target.id}/series/${foreign.id}/contents/${owned.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ).andExpectInvalidRequest(message) + } + + @Test + @DisplayName("타 owner 수정은 400 invalid_request로 막고 DB와 event side effect를 남기지 않는다") + fun shouldRejectCrossOwnerMutationWithoutSideEffects() { + val target = createCharacter("series-contract-mutation-target") + val foreign = createCharacter("series-contract-mutation-foreign") + val foreignSeries = saveSeries(foreign.creatorMember!!, saveGenre("series-contract-mutation-genre"), "foreign mutation") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}" + ) + .file(MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest("Invalid request.") + + entityManager.clear() + assertEquals("foreign mutation", entityManager.find(Series::class.java, foreignSeries.id).title) + Mockito.verifyNoInteractions(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any()) + } + + private fun ResultActions.andExpectInvalidRequest(message: String): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre = SeriesGenre(genre = name).also(entityManager::persist) + + private fun saveSeries(owner: Member, genre: SeriesGenre, title: String, isActive: Boolean = true): Series { + return Series( + title = title, + introduction = "introduction for $title", + state = SeriesState.PROCEEDING, + writer = "writer", + studio = "studio", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive, + orders = 1 + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + 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 replacePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(legacySeriesService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt new file mode 100644 index 00000000..a7c472cd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt @@ -0,0 +1,103 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +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.hamcrest.Matchers.nullValue +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.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.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 AiCharacterAdminSeriesGenreTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager +) { + @Test + @DisplayName("시리즈 장르 목록은 활성 장르만 orders 오름차순 직접 배열로 반환한다") + fun shouldReturnActiveGenresOrderedByOrders() { + val second = saveGenre("second", isAdult = true, orders = 20) + val first = saveGenre("first", isAdult = false, orders = 10) + saveGenre("inactive", isAdult = false, isActive = false, orders = 1) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get(PATH).with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(first.id)) + .andExpect(jsonPath("$.data[0].genre").value("first")) + .andExpect(jsonPath("$.data[0].isAdult").value(false)) + .andExpect(jsonPath("$.data[1].id").value(second.id)) + .andExpect(jsonPath("$.data[1].genre").value("second")) + .andExpect(jsonPath("$.data[1].isAdult").value(true)) + .andExpect(jsonPath("$.data[2]").doesNotExist()) + } + + @Test + @DisplayName("시리즈 장르 목록은 빈 목록과 ADMIN 공통 경계를 유지한다") + fun shouldReturnEmptyListAndKeepAdminBoundary() { + mockMvc.perform(get(PATH).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(0)) + + mockMvc.perform(get(PATH)) + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun saveGenre( + name: String, + isAdult: Boolean, + isActive: Boolean = true, + orders: Int + ): SeriesGenre { + return SeriesGenre( + genre = name, + isAdult = isAdult, + isActive = isActive, + orders = orders + ).also(entityManager::persist) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private companion object { + const val PATH = "/api/v2/admin/ai-characters/series-genres" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt new file mode 100644 index 00000000..06f738bd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt @@ -0,0 +1,1156 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +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.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.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 +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +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.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 org.springframework.web.multipart.support.MissingServletRequestPartException +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesMutationTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val legacyService: CreatorAdminContentSeriesService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalSeriesServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublisher() { + originalSeriesServicePublisher = replaceSeriesServicePublisher(applicationEventPublisher) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/series-cover")) + } + + @AfterEach + fun restoreActualServicePublisher() { + replaceSeriesServicePublisher(originalSeriesServicePublisher) + } + + @Test + @DisplayName("생성은 target owner로 레거시 키워드·표지·이벤트 동작을 적용하고 v2 null 성공 envelope을 반환한다") + fun shouldCreateOwnedSeriesThroughLegacyMutationPath() { + val character = createCharacter("series-mutation-create-character") + val genre = saveGenre("series-mutation-create-genre") + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file( + requestPart( + """ + { + "title":"created series", + "introduction":"created introduction", + "publishedDaysOfWeek":["MON","WED"], + "keyword":"#night night #walk", + "genreId":${genre.id}, + "isAdult":true, + "writer":"created writer", + "studio":"created studio" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + + val created = findSeriesByTitle("created series")!! + assertEquals(character.creatorMember!!.id, created.member!!.id) + assertEquals(genre.id, created.genre!!.id) + assertEquals( + setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), + created.publishedDaysOfWeek + ) + assertEquals(setOf("#night", "#walk"), created.keywordList.map { it.keyword!!.tag }.toSet()) + assertTrue(created.coverImage!!.startsWith("series_cover/${created.id}/${created.id}-cover")) + assertTrue(created.isAdult) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageDetectEvent + assertEquals(created.id, event.id) + assertEquals("created series created introduction #night night #walk", event.query) + assertEquals(LanguageDetectTargetType.SERIES, event.targetType) + } + + @Test + @DisplayName("수정은 활성 owner 시리즈에 이미지와 레거시 전체 field를 반영한다") + fun shouldUpdateOwnedActiveSeriesThroughLegacyMutationPath() { + val character = createCharacter("series-mutation-update-character") + val oldGenre = saveGenre("series-mutation-old-genre") + val newGenre = saveGenre("series-mutation-new-genre") + val series = saveSeries(character.creatorMember!!, oldGenre, "before update") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file( + requestPart( + """ + { + "title":"after update", + "introduction":"after introduction", + "publishedDaysOfWeek":["FRI"], + "genreId":${newGenre.id}, + "isAdult":true, + "state":"COMPLETE", + "isActive":true, + "writer":"after writer", + "studio":"after studio" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + + val updated = entityManager.find(Series::class.java, series.id) + assertEquals("after update", updated.title) + assertEquals("after introduction", updated.introduction) + assertEquals(setOf(SeriesPublishedDaysOfWeek.FRI), updated.publishedDaysOfWeek) + assertEquals(newGenre.id, updated.genre!!.id) + assertEquals(SeriesState.COMPLETE, updated.state) + assertTrue(updated.isAdult) + assertTrue(updated.isActive) + assertEquals("after writer", updated.writer) + assertEquals("after studio", updated.studio) + assertTrue(updated.coverImage!!.startsWith("series_cover/${series.id}/${series.id}-cover")) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageTranslationEvent + assertEquals(series.id, event.id) + assertEquals(LanguageTranslationTargetType.SERIES, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("DELETE 시리즈는 계약 밖 endpoint라 405로 거부하고 시리즈를 변경하지 않는다") + fun shouldRejectSeriesDeleteEndpointWithoutMutation() { + val character = createCharacter("series-mutation-delete-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-mutation-delete-genre"), + "delete target" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .with(adminAuthentication()) + ) + + result.andExpect(status().isMethodNotAllowed) + entityManager.flush() + entityManager.clear() + assertTrue(entityManager.find(Series::class.java, series.id).isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정 isActive=false는 활성 owner 시리즈를 soft delete하고 v2 목록에서 제외한다") + fun shouldSoftDeleteOwnedActiveSeriesThroughUpdateIsActiveFalse() { + val character = createCharacter("series-mutation-soft-delete-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-mutation-soft-delete-genre"), + "soft delete target" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(requestPart("""{"isActive":false}""")) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + assertFalse(entityManager.find(Series::class.java, series.id).isActive) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.items.length()").value(0)) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("타 owner 시리즈 수정은 owner 검증에서 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectCrossOwnerUpdateBeforeSideEffects() { + val targetCharacter = createCharacter("series-cross-owner-update-target") + val foreignCharacter = createCharacter("series-cross-owner-update-foreign") + val foreignSeries = saveSeries( + foreignCharacter.creatorMember!!, + saveGenre("series-cross-owner-update-genre"), + "foreign update" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${targetCharacter.id}/series/${foreignSeries.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"rejected update"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + entityManager.clear() + assertEquals("foreign update", entityManager.find(Series::class.java, foreignSeries.id).title) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("타 owner 시리즈 DELETE도 계약 밖 endpoint라 owner 검증 없이 405로 거부한다") + fun shouldRejectCrossOwnerDeleteEndpointWithoutMutation() { + val targetCharacter = createCharacter("series-cross-owner-delete-target") + val foreignCharacter = createCharacter("series-cross-owner-delete-foreign") + val foreignSeries = saveSeries( + foreignCharacter.creatorMember!!, + saveGenre("series-cross-owner-delete-genre"), + "foreign delete" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${targetCharacter.id}/series/${foreignSeries.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpect(status().isMethodNotAllowed) + entityManager.clear() + assertTrue(entityManager.find(Series::class.java, foreignSeries.id).isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("비활성 AI 캐릭터 생성은 target 검증에서 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectInactiveTargetCreateBeforeSideEffects() { + val character = createCharacter("series-inactive-target-create-character") + val genre = saveGenre("series-inactive-target-create-genre") + character.isActive = false + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, "inactive target create"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("미존재 양수 장르 생성은 legacy 호출 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectMissingGenreCreateBeforeSideEffects() { + val character = createCharacter("series-missing-genre-create-character") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(Long.MAX_VALUE, "missing genre create"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "0,ko,잘못된 요청입니다.", + "0,en,Invalid request.", + "0,ja,無効なリクエストです。", + "-1,ko,잘못된 요청입니다.", + "-1,en,Invalid request.", + "-1,ja,無効なリクエストです。" + ) + @DisplayName("0 이하 장르 생성은 legacy 호출 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectNonPositiveGenreCreateBeforeSideEffects(genreId: Long, language: String, message: String) { + val character = createCharacter("series-non-positive-genre-create-$genreId-$language") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genreId, "non positive genre create $genreId $language"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @ValueSource(strings = ["genreId", "isAdult"]) + @DisplayName("생성은 primitive null을 레거시 호출 전에 400으로 거부한다") + fun shouldRejectNullPrimitiveCreateFieldsBeforeSideEffects(field: String) { + val character = createCharacter("series-null-$field-create-character") + val genre = saveGenre("series-null-$field-create-genre") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + val genreIdValue = if (field == "genreId") "null" else genre.id.toString() + val isAdultValue = if (field == "isAdult") "null" else "false" + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file( + requestPart( + """ + { + "title":"null $field create", + "introduction":"introduction", + "publishedDaysOfWeek":["MON"], + "keyword":"#keyword", + "genreId":$genreIdValue, + "isAdult":$isAdultValue + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("비활성 owner 시리즈 수정은 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectInactiveSeriesUpdateBeforeSideEffects() { + val character = createCharacter("series-inactive-update-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-inactive-update-genre"), + "inactive update", + isActive = false + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"rejected inactive update"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + entityManager.clear() + val unchanged = entityManager.find(Series::class.java, series.id) + assertEquals("inactive update", unchanged.title) + assertFalse(unchanged.isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("미존재 양수 장르 수정은 이미지 업로드 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectMissingGenreUpdateBeforeSideEffects() { + val character = createCharacter("series-missing-genre-update-character") + val genre = saveGenre("series-missing-genre-update-genre") + val series = saveSeries(character.creatorMember!!, genre, "missing genre update") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"genreId":${Long.MAX_VALUE}}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + entityManager.clear() + val unchanged = entityManager.find(Series::class.java, series.id) + assertEquals(genre.id, unchanged.genre!!.id) + assertTrue(unchanged.coverImage!!.startsWith("series/missing-genre-update")) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "0,ko,잘못된 요청입니다.", + "0,en,Invalid request.", + "0,ja,無効なリクエストです。", + "-1,ko,잘못된 요청입니다.", + "-1,en,Invalid request.", + "-1,ja,無効なリクエストです。" + ) + @DisplayName("0 이하 장르 수정은 이미지 업로드 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectNonPositiveGenreUpdateBeforeSideEffects(genreId: Long, language: String, message: String) { + val character = createCharacter("series-non-positive-genre-update-$genreId-$language") + val genre = saveGenre("series-non-positive-genre-update-genre-$genreId-$language") + val series = saveSeries(character.creatorMember!!, genre, "non positive genre update $genreId $language") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"genreId":$genreId}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + val unchanged = entityManager.find(Series::class.java, series.id) + assertEquals(genre.id, unchanged.genre!!.id) + assertTrue(unchanged.coverImage!!.startsWith("series/non-positive-genre-update")) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("비활성 owner 시리즈 DELETE도 계약 밖 endpoint라 405로 거부한다") + fun shouldRejectInactiveSeriesDeleteEndpointWithoutMutation() { + val character = createCharacter("series-inactive-delete-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-inactive-delete-genre"), + "inactive delete", + isActive = false + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpect(status().isMethodNotAllowed) + entityManager.clear() + assertFalse(entityManager.find(Series::class.java, series.id).isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("미존재 시리즈 수정은 400이고 DELETE는 계약 밖 endpoint라 405로 실패한다") + fun shouldRejectMissingSeriesMutationsBeforeSideEffects() { + val character = createCharacter("series-missing-mutation-character") + entityManager.flush() + entityManager.clear() + + val updateResult = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${Long.MAX_VALUE}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"missing"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + updateResult.andExpectInvalidRequest() + + val deleteResult = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${Long.MAX_VALUE}") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + deleteResult.andExpect(status().isMethodNotAllowed) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("생성 validation SodaException key는 v2 400 envelope으로 보존한다") + fun shouldPreserveLegacyCreateValidationKeyAsBadRequest() { + val character = createCharacter("series-create-validation-character") + val genre = saveGenre("series-create-validation-genre") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, " "))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Please enter a series title.")) + .andReturn() + + val exception = result.resolvedException as SodaException + assertEquals("creator.admin.series.title_required", exception.messageKey) + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성 image part 누락은 MVC binding에서 invalid request로 거부한다") + fun shouldRejectMissingCreateImagePartBeforeLegacyService(language: String, message: String) { + val character = createCharacter("series-create-cover-required-character") + val genre = saveGenre("series-create-cover-required-genre") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(requestPart(validCreateRequest(genre.id!!, "cover required"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonCreateRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = createCharacter("series-create-request-media-type-character") + val genre = saveGenre("series-create-request-media-type-genre") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, "rejected media type"), requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonUpdateRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = createCharacter("series-update-request-media-type-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-update-request-media-type-genre"), + "before media type update" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"rejected media type update"}""", requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + entityManager.clear() + assertEquals("before media type update", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectUndefinedCreateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-create-undefined-part-$language") + val genre = saveGenre("series-create-undefined-part-genre-$language") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file(requestPart(validCreateRequest(genre.id!!, "undefined create part $language"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedCreateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-create-filename-less-part-$language") + val genre = saveGenre("series-create-filename-less-part-genre-$language") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, "filename less create part $language"))) + .part(MockPart("unexpected", "unexpected".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectUndefinedUpdateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-update-undefined-part-$language") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-update-undefined-part-genre-$language"), + "before undefined update $language" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file(requestPart("""{"title":"after undefined update"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("before undefined update $language", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedUpdateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-update-filename-less-part-$language") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-update-filename-less-part-genre-$language"), + "before filename less update $language" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"after filename less update"}""")) + .part(MockPart("unexpected", "unexpected".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("before filename less update $language", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("생성 request part 누락은 기존 400 binding 오류를 유지한다") + fun shouldPreserveMissingCreateRequestPartAsBadRequest() { + val character = createCharacter("series-missing-create-request-character") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정 request part 누락은 기존 400 binding 오류를 유지한다") + fun shouldPreserveMissingUpdateRequestPartAsBadRequest() { + val character = createCharacter("series-missing-update-request-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-missing-update-request-genre"), + "before missing request update" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + entityManager.clear() + assertEquals("before missing request update", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("생성은 빈 필수 image를 레거시 호출 전에 400으로 거부한다") + fun shouldRejectEmptyCreateImageBeforeLegacyService() { + val character = createCharacter("series-empty-create-image-character") + val genre = saveGenre("series-empty-create-image-genre") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(MockMultipartFile("image", "empty.png", "image/png", byteArrayOf())) + .file(requestPart(validCreateRequest(genre.id!!, "empty create image"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정은 빈 optional image를 생략으로 처리해 기존 cover를 유지한다") + fun shouldTreatEmptyUpdateImageAsOmittedImage() { + val character = createCharacter("series-empty-update-image-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-empty-update-image-genre"), + "empty update image" + ) + val beforeCoverImage = series.coverImage + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(MockMultipartFile("image", "empty.png", "image/png", byteArrayOf())) + .file(requestPart("""{"title":"empty update image changed"}""")) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + val updated = entityManager.find(Series::class.java, series.id) + assertEquals("empty update image changed", updated.title) + assertEquals(beforeCoverImage, updated.coverImage) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("수정은 빈 optional image만 있으면 기존 no changes 400을 유지한다") + fun shouldPreserveNoChangesWhenOnlyEmptyUpdateImageExists() { + val character = createCharacter("series-empty-update-image-only-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-empty-update-image-only-genre"), + "empty update image only" + ) + val beforeCoverImage = series.coverImage + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(MockMultipartFile("image", "empty.png", "image/png", byteArrayOf())) + .file(requestPart("{}")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("No changes to update.")) + .andReturn() + + val exception = result.resolvedException as SodaException + assertEquals("creator.admin.series.no_changes", exception.messageKey) + entityManager.clear() + assertEquals(beforeCoverImage, entityManager.find(Series::class.java, series.id).coverImage) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정 field와 image가 모두 없으면 레거시 no changes key를 v2 400 envelope으로 보존한다") + fun shouldPreserveLegacyNoChangesKeyAsBadRequest() { + val character = createCharacter("series-no-changes-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-no-changes-genre"), + "no changes" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(requestPart("{}")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("No changes to update.")) + .andReturn() + + val exception = result.resolvedException as SodaException + assertEquals("creator.admin.series.no_changes", exception.messageKey) + entityManager.clear() + assertTrue(entityManager.find(Series::class.java, series.id).isActive) + assertNoExternalSideEffects() + } + + private fun ResultActions.andExpectNullSuccess() { + andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(nullValue())) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun ResultActions.andExpectInvalidRequest() { + andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun assertNoExternalSideEffects() { + assertActualSeriesServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun saveSeries( + owner: Member, + genre: SeriesGenre, + title: String, + isActive: Boolean = true + ): Series { + return Series( + title = title, + introduction = "introduction for $title", + state = SeriesState.PROCEEDING, + writer = "writer", + studio = "studio", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive, + orders = 1 + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun imagePart() = MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1, 2, 3)) + + private fun requestPart( + json: String, + contentType: String? = MediaType.APPLICATION_JSON_VALUE + ) = MockMultipartFile( + "request", + "request.json", + contentType, + json.toByteArray() + ) + + private fun validCreateRequest(genreId: Long, title: String): String { + return """ + { + "title":"$title", + "introduction":"introduction", + "publishedDaysOfWeek":["MON"], + "keyword":"#keyword", + "genreId":$genreId + } + """.trimIndent() + } + + private fun countSeries(): Long { + return entityManager.createQuery("select count(s) from Series s", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun findSeriesByTitle(title: String): Series? { + return entityManager.createQuery( + "select s from Series s where s.title = :title", + Series::class.java + ).setParameter("title", title).resultList.firstOrNull() + } + + private fun replaceSeriesServicePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(legacyService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualSeriesServicePublisher(publisher: ApplicationEventPublisher) { + val target = AopTestUtils.getTargetObject(legacyService) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt new file mode 100644 index 00000000..4beac33a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt @@ -0,0 +1,254 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +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.hamcrest.Matchers.nullValue +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.MediaType +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.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +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(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesOrderTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val seriesRepository: CreatorAdminContentSeriesRepository +) { + @Test + @DisplayName("소유한 active series만 요청 순서대로 1..n으로 재정렬하고 성공 응답은 null data이다") + fun shouldReorderOwnedActiveSeriesInRequestOrder() { + val character = createCharacter("series-order-success-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "first series", orders = 3) + val second = saveSeries(owner, "second series", orders = 2) + val third = saveSeries(owner, "third series", orders = 1) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to listOf(third.id, first.id, second.id)))) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(nullValue())) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(third.id, first.id, second.id), orderedSeriesIds(owner.id!!)) + assertEquals(listOf(1, 2, 3), orderedSeriesOrders(owner.id!!)) + } + + @Test + @DisplayName("cross-owner, missing, duplicate, inactive series ID는 400 invalid_request로 거부하고 순서는 유지된다") + fun shouldRejectInvalidSeriesIdsWithoutMutation() { + val character = createCharacter("series-order-invalid-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-order-invalid-other-character") + val first = saveSeries(owner, "order invalid first", orders = 1) + val second = saveSeries(owner, "order invalid second", orders = 2) + val inactive = saveSeries(owner, "order invalid inactive", isActive = false, orders = 3) + val foreign = saveSeries(otherCharacter.creatorMember!!, "order invalid foreign", orders = 1) + entityManager.flush() + entityManager.clear() + + listOf( + listOf(first.id, foreign.id, second.id), + listOf(first.id, Long.MAX_VALUE, second.id), + listOf(first.id, first.id, second.id), + listOf(first.id, inactive.id, second.id), + emptyList() + ).forEach { ids -> + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to ids))) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(first.id, second.id, inactive.id), orderedSeriesIds(owner.id!!)) + assertEquals(listOf(1, 2, 3), orderedSeriesOrders(owner.id!!)) + } + } + + @Test + @DisplayName("순서 변경 body에 계약 밖 field가 있으면 400으로 거부하고 순서를 변경하지 않는다") + fun shouldRejectOrderUnknownFieldWithoutMutation() { + val character = createCharacter("series-order-unknown-field-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "order unknown first", orders = 1) + val second = saveSeries(owner, "order unknown second", orders = 2) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${second.id},${first.id}],"unexpected":true}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(first.id, second.id), orderedSeriesIds(owner.id!!)) + assertEquals(listOf(1, 2), orderedSeriesOrders(owner.id!!)) + } + + @Test + @DisplayName("동일 owner 순서 변경은 마지막 요청 결과를 남긴다") + fun shouldKeepLastOrderRequestResult() { + val character = createCharacter("series-order-last-request-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "last order first", orders = 1) + val second = saveSeries(owner, "last order second", orders = 2) + val third = saveSeries(owner, "last order third", orders = 3) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to listOf(third.id, second.id, first.id)))) + .with(adminAuthentication()) + ).andExpect(status().isOk) + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to listOf(second.id, first.id, third.id)))) + .with(adminAuthentication()) + ).andExpect(status().isOk) + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(second.id, first.id, third.id), orderedSeriesIds(owner.id!!)) + } + + @Test + @DisplayName("동시 순서 변경용 잠금은 ID 오름차순으로 owner active series를 선점한다") + fun shouldLockOwnedActiveSeriesInIdOrderBeforeReorder() { + val character = createCharacter("series-order-lock-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "lock first", orders = 1) + val second = saveSeries(owner, "lock second", orders = 2) + val third = saveSeries(owner, "lock third", orders = 3) + entityManager.flush() + entityManager.clear() + + val lockedIds = seriesRepository.findActiveByCreatorIdAndIdInForUpdate( + owner.id!!, + listOf(third.id!!, first.id!!, second.id!!) + ).map { it.id } + + assertEquals(listOf(first.id, second.id, third.id), lockedIds) + } + + private fun ResultActions.andExpectInvalidRequest(): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveSeries( + owner: Member, + title: String, + isActive: Boolean = true, + orders: Int + ): Series { + return Series( + title = title, + introduction = "introduction for $title", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive, + orders = orders + ).apply { + member = owner + genre = saveGenre(title) + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun orderedSeriesIds(ownerId: Long): List { + return entityManager.createQuery( + "select s.id from Series s where s.member.id = :ownerId order by s.orders asc, s.id asc", + java.lang.Long::class.java + ) + .setParameter("ownerId", ownerId) + .resultList + .map(java.lang.Long::toLong) + } + + private fun orderedSeriesOrders(ownerId: Long): List { + return entityManager.createQuery( + "select s.orders from Series s where s.member.id = :ownerId order by s.orders asc, s.id asc", + Int::class.javaObjectType + ) + .setParameter("ownerId", ownerId) + .resultList + .map { it as Int } + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt new file mode 100644 index 00000000..1b90e20c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt @@ -0,0 +1,343 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +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.hamcrest.Matchers.nullValue +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.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +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.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("시리즈 목록은 활성 소유 시리즈만 순서대로 레거시 전체 필드로 반환한다") + fun shouldReturnExactLegacyListFieldsAndExcludeInactiveSeries() { + val character = createCharacter("series-list-character") + val owner = character.creatorMember!! + val genre = saveGenre("series-list-genre") + val first = saveSeries( + owner = owner, + genre = genre, + title = "first series", + orders = 1, + state = SeriesState.SUSPEND, + days = mutableSetOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), + isAdult = true, + writer = null + ) + val second = saveSeries(owner, genre, "second series", orders = 2) + val inactive = saveSeries(owner, genre, "inactive series", orders = 0, isActive = false) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .param("page", "0") + .param("size", "20") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + val items = data.path("items") + val item = items.path(0) + assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet()) + assertEquals(2, data.path("totalCount").asInt()) + assertEquals(listOf(first.id, second.id), items.map { it.path("seriesId").asLong() }) + assertTrue(items.none { it.path("seriesId").asLong() == inactive.id }) + assertEquals( + setOf( + "seriesId", + "title", + "introduction", + "coverImageUrl", + "publishedDaysOfWeek", + "genreId", + "isAdult", + "state", + "isActive", + "writer", + "studio" + ), + item.fieldNames().asSequence().toSet() + ) + assertEquals("first series", item.path("title").asText()) + assertEquals("introduction for first series", item.path("introduction").asText()) + assertEquals("https://test.cloudfront.net/series/first-series.png", item.path("coverImageUrl").asText()) + assertEquals(setOf("MON", "WED"), item.path("publishedDaysOfWeek").map { it.asText() }.toSet()) + assertEquals(genre.id, item.path("genreId").asLong()) + assertTrue(item.path("isAdult").asBoolean()) + assertEquals("SUSPEND", item.path("state").asText()) + assertTrue(item.path("isActive").asBoolean()) + assertTrue(item.path("writer").isNull) + assertEquals("studio", item.path("studio").asText()) + } + + @Test + @DisplayName("시리즈 상세 data는 목록 item과 동일한 필드와 타입으로 반환한다") + fun shouldReturnDetailAsSingleListItemSchema() { + val character = createCharacter("series-detail-character") + val genre = saveGenre("series-detail-genre") + val series = saveSeries( + owner = character.creatorMember!!, + genre = genre, + title = "detail series", + state = SeriesState.COMPLETE, + days = mutableSetOf(SeriesPublishedDaysOfWeek.WED, SeriesPublishedDaysOfWeek.SUN), + keywords = listOf("#night") + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + assertEquals( + setOf( + "seriesId", + "title", + "introduction", + "coverImageUrl", + "publishedDaysOfWeek", + "genreId", + "isAdult", + "state", + "isActive", + "writer", + "studio" + ), + data.fieldNames().asSequence().toSet() + ) + assertEquals(series.id, data.path("seriesId").asLong()) + assertEquals("detail series", data.path("title").asText()) + assertEquals("introduction for detail series", data.path("introduction").asText()) + assertEquals("https://test.cloudfront.net/series/detail-series.png", data.path("coverImageUrl").asText()) + assertEquals(setOf("SUN", "WED"), data.path("publishedDaysOfWeek").map { it.asText() }.toSet()) + assertEquals(genre.id, data.path("genreId").asLong()) + assertEquals("COMPLETE", data.path("state").asText()) + assertTrue(data.path("isActive").asBoolean()) + assertEquals("writer", data.path("writer").asText()) + assertEquals("studio", data.path("studio").asText()) + } + + @Test + @DisplayName("시리즈 상세는 미존재 비활성 타 소유자 리소스를 같은 400 오류로 거부한다") + fun shouldRejectMissingInactiveAndCrossOwnerDetail() { + val character = createCharacter("series-detail-owner-character") + val otherCharacter = createCharacter("series-detail-other-character") + val genre = saveGenre("series-invalid-detail-genre") + val inactive = saveSeries(character.creatorMember!!, genre, "inactive detail", isActive = false) + val foreign = saveSeries(otherCharacter.creatorMember!!, genre, "foreign detail") + entityManager.flush() + entityManager.clear() + + listOf(Long.MAX_VALUE, inactive.id!!, foreign.id!!).forEach { seriesId -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/$seriesId") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 시리즈 목록을 400으로 거부한다") + fun shouldRejectInactiveTargetList() { + val character = createCharacter("inactive-target-series-list-character") + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 시리즈 상세를 400으로 거부한다") + fun shouldRejectInactiveTargetDetail() { + val character = createCharacter("inactive-target-series-detail-character") + val series = saveSeries( + owner = character.creatorMember!!, + genre = saveGenre("inactive-target-series-detail-genre"), + title = "inactive target detail" + ) + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("시리즈 목록은 최소 size 1과 다음 page 경계를 그대로 적용한다") + fun shouldRespectMinimumSizeAndNextPageBoundary() { + val character = createCharacter("series-pagination-character") + val genre = saveGenre("series-pagination-genre") + val first = saveSeries(character.creatorMember!!, genre, "page first", orders = 1) + val second = saveSeries(character.creatorMember!!, genre, "page second", orders = 2) + saveSeries(character.creatorMember!!, genre, "page third", orders = 3) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .param("page", "1") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].seriesId").value(second.id)) + .andExpect(jsonPath("$.data.items[0].seriesId").value(org.hamcrest.Matchers.not(first.id))) + } + + @Test + @DisplayName("시리즈 목록은 음수 page와 0 size를 400으로 거부한다") + fun shouldRejectInvalidPaginationBoundaries() { + val character = createCharacter("series-invalid-pagination-character") + entityManager.flush() + entityManager.clear() + + listOf("-1" to "20", "0" to "0").forEach { (page, size) -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .param("page", page) + .param("size", size) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun saveSeries( + owner: Member, + genre: SeriesGenre, + title: String, + orders: Int = 1, + isActive: Boolean = true, + state: SeriesState = SeriesState.PROCEEDING, + days: MutableSet = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + keywords: List = emptyList(), + isAdult: Boolean = false, + writer: String? = "writer" + ): Series { + val series = Series( + title = title, + introduction = "introduction for $title", + state = state, + writer = writer, + studio = "studio", + publishedDaysOfWeek = days, + isAdult = isAdult, + isActive = isActive, + orders = orders + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + } + keywords.forEach { tag -> + val hashTag = HashTag(tag).also(entityManager::persist) + series.keywordList.add( + SeriesKeyword().apply { + this.series = series + keyword = hashTag + } + ) + } + entityManager.persist(series) + return series + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt new file mode 100644 index 00000000..6ca1b11f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt @@ -0,0 +1,490 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyCreatorAdminSeriesCharacterizationTest @Autowired constructor( + private val repository: CreatorAdminContentSeriesRepository, + private val adminRepository: AdminContentSeriesRepository, + private val genreRepository: CreatorAdminContentSeriesGenreRepository, + private val hashTagRepository: HashTagRepository, + private val audioContentRepository: AudioContentRepository, + private val messageSource: SodaMessageSource, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + private lateinit var eventPublisher: ApplicationEventPublisher + private lateinit var service: CreatorAdminContentSeriesService + + @BeforeEach + fun setUp() { + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/series-cover")) + eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + service = CreatorAdminContentSeriesService( + repository = repository, + genreRepository = genreRepository, + hashTagRepository = hashTagRepository, + audioContentRepository = audioContentRepository, + s3Uploader = S3Uploader(amazonS3Client), + objectMapper = jacksonObjectMapper(), + applicationEventPublisher = eventPublisher, + coverImageBucket = "test-bucket", + coverImageHost = "https://cover.example.com" + ) + } + + @Test + @DisplayName("기존 시리즈 생성은 키워드를 정규화하고 표지 업로드와 언어 감지 이벤트를 남긴다") + fun shouldCreateSeriesWithNormalizedKeywordsCoverAndLanguageDetectEvent() { + val owner = saveMember("legacy-series-create-owner") + val genre = saveGenre("legacy-create-genre") + + service.createSeries( + coverImage = MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1, 2, 3)), + requestString = """ + { + "title":"legacy created series", + "introduction":"legacy introduction", + "publishedDaysOfWeek":["MON","WED"], + "keyword":"#night night #walk", + "genreId":${genre.id}, + "isAdult":true, + "writer":"legacy writer", + "studio":"legacy studio" + } + """.trimIndent(), + member = owner + ) + entityManager.flush() + + val created = repository.findAll().single { it.title == "legacy created series" } + assertEquals(owner.id, created.member!!.id) + assertEquals(genre.id, created.genre!!.id) + assertEquals(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), created.publishedDaysOfWeek) + assertEquals(setOf("#night", "#walk"), created.keywordList.map { it.keyword!!.tag }.toSet()) + assertTrue(created.coverImage!!.startsWith("series_cover/${created.id}/${created.id}-cover")) + assertTrue(created.isActive) + assertTrue(created.isAdult) + Mockito.verify(amazonS3Client).putObject(Mockito.any()) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageDetectEvent + assertEquals(created.id, event.id) + assertEquals("legacy created series legacy introduction #night night #walk", event.query) + assertEquals(LanguageDetectTargetType.SERIES, event.targetType) + } + + @Test + @DisplayName("기존 목록은 활성 소유 시리즈만 순서대로 반환하지만 상세는 비활성 시리즈도 반환한다") + fun shouldListOnlyActiveOwnedSeriesButAllowInactiveOwnedDetail() { + val owner = saveMember("legacy-series-list-owner") + val otherOwner = saveMember("legacy-series-list-other-owner") + val genre = saveGenre("legacy-list-genre") + val first = saveSeries(owner, genre, "first active series", orders = 1) + val second = saveSeries(owner, genre, "second active series", orders = 2) + val inactive = saveSeries( + owner = owner, + genre = genre, + title = "inactive series", + isActive = false, + orders = 0, + state = SeriesState.COMPLETE, + days = mutableSetOf(SeriesPublishedDaysOfWeek.WED, SeriesPublishedDaysOfWeek.SUN), + keywords = listOf("#inactive") + ) + saveSeries(otherOwner, genre, "other owner series", orders = 0) + entityManager.flush() + entityManager.clear() + + val response = service.getSeriesList(offset = 0, limit = 20, creatorId = owner.id!!) + + assertEquals(2, response.totalCount) + assertEquals(listOf(first.id, second.id), response.items.map { it.seriesId }) + assertEquals("https://cover.example.com/series/first-active-series.png", response.items.first().coverImageUrl) + assertEquals(genre.id, response.items.first().genreId) + assertTrue(response.items.all { it.isActive }) + + val inactiveDetail = service.getDetail(id = inactive.id!!, memberId = owner.id!!) + assertEquals(inactive.id, inactiveDetail.seriesId) + assertEquals("일, 수", inactiveDetail.publishedDaysOfWeek) + assertEquals("완결", inactiveDetail.state) + assertEquals("#inactive", inactiveDetail.keywords) + assertNull(adminRepository.findByIdAndActiveTrue(inactive.id!!)) + } + + @Test + @DisplayName("기존 수정은 일반 필드와 soft delete를 함께 반영하고 비활성 시리즈를 목록에서 제외한다") + fun shouldModifyFieldsAndSoftDeleteInOneRequest() { + val owner = saveMember("legacy-series-update-owner") + val oldGenre = saveGenre("legacy-old-genre") + val newGenre = saveGenre("legacy-new-genre") + val series = saveSeries(owner, oldGenre, "before title") + val originalCover = series.coverImage + + service.modifySeries( + coverImage = null, + requestString = """ + { + "seriesId":${series.id}, + "title":"after title", + "introduction":"after introduction", + "publishedDaysOfWeek":["FRI"], + "genreId":${newGenre.id}, + "isAdult":true, + "state":"COMPLETE", + "isActive":false, + "writer":"after writer", + "studio":"after studio" + } + """.trimIndent(), + member = owner + ) + entityManager.flush() + + assertEquals("after title", series.title) + assertEquals("after introduction", series.introduction) + assertEquals(setOf(SeriesPublishedDaysOfWeek.FRI), series.publishedDaysOfWeek) + assertEquals(newGenre.id, series.genre!!.id) + assertEquals(SeriesState.COMPLETE, series.state) + assertEquals(originalCover, series.coverImage) + assertTrue(series.isAdult) + assertFalse(series.isActive) + assertEquals("after writer", series.writer) + assertEquals("after studio", series.studio) + assertEquals(0, service.getSeriesList(0, 20, owner.id!!).totalCount) + assertEquals("after title", service.getDetail(series.id!!, owner.id!!).title) + assertNull(adminRepository.findByIdAndActiveTrue(series.id!!)) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent + assertEquals(series.id, event.id) + assertEquals(kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType.SERIES, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("기존 요청 검증과 소유권 실패는 고유 message key를 사용하고 generic 오류 번역은 세 언어로 유지된다") + fun shouldExposeLegacyValidationOwnershipAndLocalizationBaseline() { + val validRequest = CreateSeriesRequest( + title = "title", + introduction = "introduction", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + keyword = "#keyword", + genreId = 1L + ) + val validationKeys = listOf( + assertThrows(SodaException::class.java) { validRequest.copy(title = " ").toSeries() }.messageKey, + assertThrows(SodaException::class.java) { validRequest.copy(introduction = " ").toSeries() }.messageKey, + assertThrows(SodaException::class.java) { validRequest.copy(keyword = " ").toSeries() }.messageKey, + assertThrows(SodaException::class.java) { validRequest.copy(genreId = 0).toSeries() }.messageKey, + assertThrows(SodaException::class.java) { + validRequest.copy(publishedDaysOfWeek = mutableSetOf()).toSeries() + }.messageKey, + assertThrows(SodaException::class.java) { + validRequest.copy( + publishedDaysOfWeek = mutableSetOf( + SeriesPublishedDaysOfWeek.RANDOM, + SeriesPublishedDaysOfWeek.MON + ) + ).toSeries() + }.messageKey + ) + assertEquals( + listOf( + "creator.admin.series.title_required", + "creator.admin.series.introduction_required", + "creator.admin.series.keyword_required", + "creator.admin.series.genre_required", + "creator.admin.series.published_days_required", + "creator.admin.series.published_days_random_exclusive" + ), + validationKeys + ) + + val owner = saveMember("legacy-series-error-owner") + val otherOwner = saveMember("legacy-series-error-other-owner") + val genre = saveGenre("legacy-error-genre") + val ownerSeries = saveSeries(owner, genre, "owner series") + val otherSeries = saveSeries(otherOwner, genre, "other series") + assertEquals( + "creator.admin.series.cover_image_required", + assertThrows(SodaException::class.java) { service.createSeries(null, "{}", owner) }.messageKey + ) + assertEquals( + "creator.admin.series.no_changes", + assertThrows(SodaException::class.java) { + service.modifySeries(null, """{"seriesId":${ownerSeries.id}}""", owner) + }.messageKey + ) + assertEquals( + "creator.admin.series.invalid_access", + assertThrows(SodaException::class.java) { service.getDetail(otherSeries.id!!, owner.id!!) }.messageKey + ) + assertEquals( + "creator.admin.series.no_content_added", + assertThrows(SodaException::class.java) { + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(ownerSeries.id!!, emptyList()), + owner.id!! + ) + }.messageKey + ) + assertEquals( + listOf("잘못된 요청입니다.", "Invalid request.", "無効なリクエストです。"), + Lang.values().map { messageSource.getMessage("common.error.invalid_request", it) } + ) + } + + @Test + @DisplayName("기존 콘텐츠 연결은 소유한 ID만 부분 반영하고 전체 owner count와 무해한 해제를 유지한다") + fun shouldPartiallyLinkOwnedContentCountAcrossSeriesAndSilentlyUnlink() { + val owner = saveMember("legacy-series-link-owner") + val otherOwner = saveMember("legacy-series-link-other-owner") + val genre = saveGenre("legacy-link-genre") + val targetSeries = saveSeries(owner, genre, "target series") + val secondSeries = saveSeries(owner, genre, "second series") + val targetContent = saveAudioContent(owner, "target audio") + val secondContent = saveAudioContent(owner, "second audio") + val foreignContent = saveAudioContent(otherOwner, "foreign audio") + + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest( + targetSeries.id!!, + listOf(targetContent.id!!, foreignContent.id!!, Long.MAX_VALUE) + ), + owner.id!! + ) + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(secondSeries.id!!, listOf(secondContent.id!!)), + owner.id!! + ) + entityManager.flush() + entityManager.clear() + + val targetResponse = service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!) + assertEquals(2, targetResponse.totalCount) + assertEquals(listOf(targetContent.id), targetResponse.items.map { it.contentId }) + assertEquals("https://cover.example.com/audio/target-audio.png", targetResponse.items.single().coverImage) + + val missingSeriesResponse = service.getSeriesContent(Long.MAX_VALUE, 0, 20, owner.id!!) + assertEquals(2, missingSeriesResponse.totalCount) + assertTrue(missingSeriesResponse.items.isEmpty()) + assertEquals( + "creator.admin.series.no_content_added", + assertThrows(SodaException::class.java) { + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(targetSeries.id!!, listOf(foreignContent.id!!, Long.MAX_VALUE)), + owner.id!! + ) + }.messageKey + ) + assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.size) + + service.removeContentInTheSeries( + RemoveContentToTheSeriesRequest(targetSeries.id!!, Long.MAX_VALUE), + owner.id!! + ) + assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.size) + service.removeContentInTheSeries( + RemoveContentToTheSeriesRequest(targetSeries.id!!, targetContent.id!!), + owner.id!! + ) + entityManager.flush() + assertTrue(service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.isEmpty()) + assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).totalCount) + } + + @Test + @DisplayName("기존 미연결 검색은 처리 완료 또는 예약된 소유 콘텐츠만 반환하고 시리즈 소유권은 검증하지 않는다") + fun shouldSearchEligibleUnlinkedOwnerContentWithoutSeriesOwnershipValidation() { + val owner = saveMember("legacy-series-search-owner") + val otherOwner = saveMember("legacy-series-search-other-owner") + val genre = saveGenre("legacy-search-genre") + val targetSeries = saveSeries(owner, genre, "search target series") + val linked = saveAudioContent(owner, "night linked") + val eligible = saveAudioContent(owner, "night eligible") + val reserved = saveAudioContent( + owner = owner, + title = "night reserved", + isActive = false, + releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0) + ) + saveAudioContent(owner, "night processing", duration = null) + saveAudioContent(owner, "night inactive", isActive = false, releaseDate = null) + saveAudioContent(otherOwner, "night foreign") + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(targetSeries.id!!, listOf(linked.id!!)), + owner.id!! + ) + entityManager.flush() + entityManager.clear() + + val result = service.searchContentNotInSeries(targetSeries.id!!, "night", owner.id!!) + assertEquals(setOf(eligible.id, reserved.id), result.map { it.contentId }.toSet()) + assertTrue(result.all { it.coverImage.startsWith("https://cover.example.com/audio/") }) + + val missingSeriesResult = service.searchContentNotInSeries(Long.MAX_VALUE, "night", owner.id!!) + assertEquals(setOf(linked.id, eligible.id, reserved.id), missingSeriesResult.map { it.contentId }.toSet()) + } + + @Test + @DisplayName("기존 순서 변경은 owner와 활성 상태를 검사하지 않고 누락 ID의 순번을 건너뛴다") + fun shouldUpdateOrdersAcrossOwnersAndSkipMissingIds() { + val owner = saveMember("legacy-series-order-owner") + val otherOwner = saveMember("legacy-series-order-other-owner") + val genre = saveGenre("legacy-order-genre") + val ownedSeries = saveSeries(owner, genre, "owned order series", orders = 10) + val foreignInactiveSeries = saveSeries( + owner = otherOwner, + genre = genre, + title = "foreign inactive order series", + isActive = false, + orders = 10 + ) + + service.updateSeriesOrders(listOf(foreignInactiveSeries.id!!, Long.MAX_VALUE, ownedSeries.id!!)) + entityManager.flush() + entityManager.clear() + + assertEquals(1, repository.findById(foreignInactiveSeries.id!!).orElseThrow().orders) + assertFalse(repository.findById(foreignInactiveSeries.id!!).orElseThrow().isActive) + assertEquals(3, repository.findById(ownedSeries.id!!).orElseThrow().orders) + } + + private fun saveMember(nickname: String): Member { + val member = Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.CREATOR + ) + entityManager.persist(member) + return member + } + + private fun saveGenre(name: String): SeriesGenre { + val genre = SeriesGenre(genre = name) + entityManager.persist(genre) + return genre + } + + private fun saveSeries( + owner: Member, + genre: SeriesGenre, + title: String, + isActive: Boolean = true, + orders: Int = 1, + state: SeriesState = SeriesState.PROCEEDING, + days: MutableSet = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + keywords: List = emptyList() + ): Series { + val series = Series( + title = title, + introduction = "introduction for $title", + state = state, + writer = "writer", + studio = "studio", + publishedDaysOfWeek = days, + isActive = isActive, + orders = orders + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + } + keywords.forEach { tag -> + val hashTag = HashTag(tag) + entityManager.persist(hashTag) + series.keywordList.add( + SeriesKeyword().apply { + this.series = series + keyword = hashTag + } + ) + } + entityManager.persist(series) + return series + } + + private fun saveAudioContent( + owner: Member, + title: String, + isActive: Boolean = true, + releaseDate: LocalDateTime? = LocalDateTime.of(2026, 7, 24, 10, 0), + duration: String? = "00:10:00" + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5, + releaseDate = releaseDate + ).apply { + member = owner + this.theme = theme + this.isActive = isActive + this.duration = duration + this.content = "audio/${title.replace(" ", "-")}.mp3" + coverImage = "audio/${title.replace(" ", "-")}.png" + } + entityManager.persist(content) + return content + } +}