diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 6f9d33b0..1d0ab8f1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -220,9 +220,6 @@ class AudioContentService( // request 내용 파싱 val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) - // 미리듣기 시간 체크 - validatePreviewTime(request.previewStartTime, request.previewEndTime) - val releaseDate = if (request.releaseDate != null) { request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm") .atZone(ZoneId.of(request.timezone)) @@ -232,11 +229,26 @@ class AudioContentService( LocalDateTime.now() } + return createAudioContent(contentFile, coverImage, request, releaseDate, member) + } + + @Transactional + fun createAudioContent( + contentFile: MultipartFile?, + coverImage: MultipartFile?, + request: CreateAudioContentRequest, + releaseDate: LocalDateTime, + member: Member + ): CreateAudioContentResponse { + if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required") + // contentFile 체크 if (contentFile == null) { throw SodaException(messageKey = "content.error.content_required") } + validatePreviewTime(request.previewStartTime, request.previewEndTime) + // 테마 체크 val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) ?: throw SodaException(messageKey = "content.error.invalid_theme") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt index df929b68..503e366a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt @@ -1,16 +1,23 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content 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.util.MultiValueMap +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") @@ -25,30 +32,33 @@ class AiCharacterAdminAudioContentController( @GetMapping("/{characterId:[0-9]+}/audio-contents") fun list( @PathVariable characterId: Long, - @RequestParam(required = false) search: String?, - @RequestParam(required = false) status: String?, + @RequestParam(value = "search_word", required = false) searchWord: String?, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int ): ApiResponse { - return ApiResponse.ok(facade.list(characterId, search, status, page, size)) + return ApiResponse.ok(facade.list(characterId, searchWord, page, size)) } @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}") fun detail( @PathVariable characterId: Long, - @PathVariable contentId: Long + @PathVariable contentId: Long, + @RequestParam queryParameters: MultiValueMap ): ApiResponse { - return ApiResponse.ok(facade.detail(characterId, contentId)) + return ApiResponse.ok(facade.detail(characterId, contentId, queryParameters.keys)) } @PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) fun create( @PathVariable characterId: Long, @RequestPart("coverImage") coverImage: MultipartFile, - @RequestPart("audioFile") audioFile: MultipartFile, - @RequestPart("request") request: String - ): ApiResponse { - return ApiResponse.ok(facade.create(characterId, coverImage, audioFile, request)) + @RequestPart("contentFile") contentFile: MultipartFile, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + return ApiResponse.ok(facade.create(characterId, coverImage, contentFile, request)) } @PutMapping( @@ -59,9 +69,96 @@ class AiCharacterAdminAudioContentController( @PathVariable characterId: Long, @PathVariable contentId: Long, @RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?, - @RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?, - @RequestPart("request") request: String - ): ApiResponse { - return ApiResponse.ok(facade.update(characterId, contentId, coverImage, audioFile, request)) + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, contentId, coverImage, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts( + multipartRequest: MultipartHttpServletRequest, + allowedParts: Set + ) { + if (multipartRequest.fileMap.keys.any { it !in allowedParts } || + multipartRequest.parts.any { it.name !in allowedParts } + ) { + 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 CREATE_MULTIPART_PARTS = setOf("coverImage", "contentFile", "request") + private val UPDATE_MULTIPART_PARTS = setOf("coverImage", "request") + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments") + fun comments( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @RequestParam queryParameters: MultiValueMap, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.comments(characterId, contentId, queryParameters.keys, page, size)) + } + + @PostMapping( + "/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments", + consumes = [MediaType.APPLICATION_JSON_VALUE] + ) + fun createComment( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @RequestBody request: String + ): ApiResponse { + facade.createComment(characterId, contentId, request) + return ApiResponse.ok(null) + } + + @PutMapping( + "/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}", + consumes = [MediaType.APPLICATION_JSON_VALUE] + ) + fun updateComment( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @PathVariable commentId: Long, + @RequestBody request: String + ): ApiResponse { + facade.updateComment(characterId, contentId, commentId, request) + return ApiResponse.ok(null) + } + + @DeleteMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}") + fun deleteComment( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @PathVariable commentId: Long + ): ApiResponse { + facade.deleteComment(characterId, contentId, commentId) + return ApiResponse.ok(null) + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}/replies") + fun replies( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @PathVariable commentId: Long, + @RequestParam queryParameters: MultiValueMap, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.replies(characterId, contentId, commentId, queryParameters.keys, page, size)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt index 24b70861..d0950374 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt @@ -1,126 +1,31 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content +import kr.co.vividnext.sodalive.content.CreateAudioContentRequest +import kr.co.vividnext.sodalive.content.CreateAudioContentResponse +import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse import kr.co.vividnext.sodalive.content.PurchaseOption -import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse +import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse +import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListItem +import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListResponse -data class AiCharacterAdminAudioContentListResponse( - val totalCount: Long, - val items: List, - val page: Int, - val size: Int, - val hasNext: Boolean -) - -data class AiCharacterAdminAudioContentListItem( - val contentId: Long, - val title: String, - val coverImageUrl: String, - val audioSignedUrl: String?, - val price: Int, - val isAdult: Boolean, - val isActive: Boolean, - val releaseDateUtc: String?, - val status: AiCharacterAdminAudioContentStatus -) - -data class AiCharacterAdminAudioContentResponse( - val contentId: Long, - val title: String, - val description: String, - val detail: String, - val coverImageUrl: String, - val audioSignedUrl: String?, - val contentUrl: String?, - val languageCode: String?, - val themeStr: String, - val tag: String, - val price: Int, - val duration: String, - val isAdult: Boolean, - val isActive: Boolean, - val isPointAvailable: Boolean, - val isCommentAvailable: Boolean, - val releaseDateUtc: String?, - val releaseDate: String?, - val totalContentCount: Int?, - val remainingContentCount: Int?, - val orderSequence: Int?, - val isActivePreview: Boolean, - val isMosaic: Boolean, - val isOnlyRental: Boolean, - val existOrdered: Boolean, - val purchaseOption: PurchaseOption, - val orderType: OrderType?, - val remainingTime: String?, - val creatorOtherContentList: List, - val sameThemeOtherContentList: List, - val isLike: Boolean, - val likeCount: Int, - val commentList: List, - val commentCount: Int, - val isPin: Boolean, - val isAvailablePin: Boolean, - val creator: AiCharacterAdminAudioContentCreatorResponse, - val previousContent: AiCharacterAdminOtherContentResponse?, - val nextContent: AiCharacterAdminOtherContentResponse?, - val buyerList: List, - val isAvailableUsePoint: Boolean, - val translated: AiCharacterAdminTranslatedContentResponse?, - val status: AiCharacterAdminAudioContentStatus, - val seriesIds: List, - val createdAtUtc: String?, - val updatedAtUtc: String? -) - -data class AiCharacterAdminOtherContentResponse( - val contentId: Long, - val title: String, - val coverUrl: String -) - -data class AiCharacterAdminAudioContentCommentResponse( - val commentId: Long, - val nickname: String, - val content: String -) - -data class AiCharacterAdminAudioContentCreatorResponse( - val creatorId: Long, - val nickname: String, - val profileImageUrl: String, - val isFollowing: Boolean, - val isFollow: Boolean, - val isNotify: Boolean -) - -data class AiCharacterAdminContentBuyerResponse( - val nickname: String, - val profileImageUrl: String -) - -data class AiCharacterAdminTranslatedContentResponse( - val title: String, - val detail: String, - val tags: String -) - -data class AiCharacterAdminAudioContentThemeResponse( - val themeId: Long, - val themeName: String, - val imageUrl: String -) +typealias AiCharacterAdminAudioContentListResponse = GetCreatorAdminContentListResponse +typealias AiCharacterAdminAudioContentListItem = GetCreatorAdminContentListItem +typealias AiCharacterAdminAudioContentResponse = GetAudioContentDetailResponse +typealias AiCharacterAdminAudioContentThemeResponse = GetAudioContentThemeResponse +typealias AiCharacterAdminAudioContentCreateResponse = CreateAudioContentResponse +typealias AiCharacterAdminAudioContentCommentListResponse = GetAudioContentCommentListResponse data class AiCharacterAdminAudioContentCreateRequest( val title: String, - val description: String, + val detail: String, + val tags: String, val price: Int, - val isAdult: Boolean = false, - val isActive: Boolean = true, - val themeId: Long, - val tags: String = "", val purchaseOption: PurchaseOption = PurchaseOption.BOTH, val limited: Int? = null, - val releaseDateUtc: String? = null, + val releaseDate: String? = null, + val themeId: Long = 0, + val isAdult: Boolean = false, val isGeneratePreview: Boolean = false, val isOnlyRental: Boolean = false, val isPointAvailable: Boolean = false, @@ -128,24 +33,38 @@ data class AiCharacterAdminAudioContentCreateRequest( val isFullDetailVisible: Boolean = true, val previewStartTime: String? = null, val previewEndTime: String? = null, - val languageCode: String? = null, - val seriesIds: List = emptyList() -) + val languageCode: String? = null +) { + fun toLegacyRequest(): CreateAudioContentRequest { + return CreateAudioContentRequest( + title = title, + detail = detail, + tags = tags, + price = price, + purchaseOption = purchaseOption, + limited = limited, + timezone = "UTC", + themeId = themeId, + isAdult = isAdult, + isGeneratePreview = isGeneratePreview, + isOnlyRental = isOnlyRental, + isPointAvailable = isPointAvailable, + isCommentAvailable = isCommentAvailable, + isFullDetailVisible = isFullDetailVisible, + previewStartTime = previewStartTime, + previewEndTime = previewEndTime, + languageCode = languageCode + ) + } +} data class AiCharacterAdminAudioContentUpdateRequest( val title: String? = null, - val description: String? = null, + val detail: String? = null, val tags: String? = null, val price: Int? = null, val isAdult: Boolean? = null, val isActive: Boolean? = null, - val releaseDateUtc: String? = null, - val seriesIds: List? = null, val isPointAvailable: Boolean? = null, val isCommentAvailable: Boolean? = null ) - -enum class AiCharacterAdminAudioContentStatus { - OPEN, - SCHEDULED -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt index 15d5b82f..e4fbe1b2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt @@ -1,12 +1,19 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper -import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContentService -import kr.co.vividnext.sodalive.content.CreateAudioContentRequest +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse +import kr.co.vividnext.sodalive.content.comment.ModifyCommentRequest +import kr.co.vividnext.sodalive.content.comment.RegisterCommentRequest import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.creator.admin.content.UpdateCreatorAdminContentRequest +import kr.co.vividnext.sodalive.extensions.toUtcIso +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.data.domain.PageRequest @@ -14,10 +21,10 @@ import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile +import java.time.DateTimeException import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset -import java.time.format.DateTimeFormatter @Service class AiCharacterAdminAudioContentFacade( @@ -27,104 +34,148 @@ class AiCharacterAdminAudioContentFacade( private val themeQueryRepository: AudioContentThemeQueryRepository, private val mapper: AiCharacterAdminAudioContentMapper, private val audioContentService: AudioContentService, - private val creatorAdminContentService: CreatorAdminContentService + private val creatorAdminContentService: CreatorAdminContentService, + private val audioContentCommentService: AudioContentCommentService ) { @Transactional(readOnly = true) fun themes(): List { - return themeQueryRepository.getActiveThemes().map { - AiCharacterAdminAudioContentThemeResponse( - themeId = it.id, - themeName = it.theme, - imageUrl = it.image - ) - } + return themeQueryRepository.getActiveThemes() } @Transactional(readOnly = true) fun list( characterId: Long, - search: String?, - status: String?, + searchWord: String?, page: Int, size: Int ): AiCharacterAdminAudioContentListResponse { val target = targetResolver.resolve(characterId) - val normalizedSearch = search?.trim().orEmpty() - if (normalizedSearch.length == 1) throw invalidRequest() - val normalizedPage = page.coerceAtLeast(0) - val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE) - val contents = repository.findPage( - creatorMemberId = target.creatorMember.id ?: throw invalidRequest(), - search = normalizedSearch, - status = parseStatus(status), - pageable = PageRequest.of(normalizedPage, normalizedSize) - ) - - return AiCharacterAdminAudioContentListResponse( - totalCount = contents.totalElements, - items = contents.content.map(mapper::toListItem), - page = normalizedPage, - size = normalizedSize, - hasNext = contents.hasNext() - ) + val pageable = PageRequest.of(normalizedPage, size.coerceAtLeast(MINIMUM_PAGE_SIZE)) + return if (searchWord == null) { + creatorAdminContentService.getAudioContentList(pageable, target.creatorMember) + } else { + creatorAdminContentService.searchAudioContent(searchWord, target.creatorMember, pageable) + } } @Transactional(readOnly = true) - fun detail(characterId: Long, contentId: Long): AiCharacterAdminAudioContentResponse { + fun detail( + characterId: Long, + contentId: Long, + queryParameterNames: Set + ): AiCharacterAdminAudioContentResponse { + if (queryParameterNames.isNotEmpty()) throw invalidRequest() val target = targetResolver.resolve(characterId) val content = repository.findByIdAndCreatorMemberId( contentId = contentId, creatorMemberId = target.creatorMember.id ?: throw invalidRequest() ) ?: throw invalidRequest() - return mapper.toResponse(content, target.creatorMember.id ?: throw invalidRequest()) + return mapper.toResponse(content) + } + + @Transactional(readOnly = true) + fun comments( + characterId: Long, + contentId: Long, + queryParameterNames: Set, + page: Int, + size: Int + ): GetAudioContentCommentListResponse { + val target = resolveOwnedActiveContent(characterId, contentId) + validateCommentQuery(queryParameterNames, page, size) + return utcCommentDates( + audioContentCommentService.getCommentList( + audioContentId = contentId, + memberId = target.creatorMember.id ?: throw invalidRequest(), + timezone = UTC_TIMEZONE, + pageable = PageRequest.of(page, size) + ), + contentId + ) + } + + @Transactional + fun createComment(characterId: Long, contentId: Long, requestString: String) { + val target = resolveOwnedActiveContent(characterId, contentId) + val request = readCommentCreateRequest(requestString, contentId) + request.parentId?.let { parentId -> + repository.findActiveRootCommentByIdAndContentId(parentId, contentId) ?: throw invalidRequest() + } + audioContentCommentService.registerComment( + member = target.creatorMember, + comment = request.comment, + audioContentId = contentId, + parentId = request.parentId, + isSecret = request.isSecret, + languageCode = request.languageCode + ) + } + + @Transactional + fun updateComment(characterId: Long, contentId: Long, commentId: Long, requestString: String) { + val target = resolveOwnedActiveContent(characterId, contentId) + val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest() + if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest() + + val request = readCommentUpdateRequest(requestString, commentId) + audioContentCommentService.modifyComment(request, target.creatorMember) + } + + @Transactional + fun deleteComment(characterId: Long, contentId: Long, commentId: Long) { + val target = resolveOwnedActiveContent(characterId, contentId) + val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest() + if (!comment.isActive) return + + audioContentCommentService.modifyComment( + ModifyCommentRequest(commentId = commentId, isActive = false), + target.creatorMember + ) + } + + @Transactional(readOnly = true) + fun replies( + characterId: Long, + contentId: Long, + commentId: Long, + queryParameterNames: Set, + page: Int, + size: Int + ): GetAudioContentCommentListResponse { + val target = resolveOwnedActiveContent(characterId, contentId) + validateCommentQuery(queryParameterNames, page, size) + repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest() + return utcCommentDates( + audioContentCommentService.getCommentReplyList( + commentId = commentId, + memberId = target.creatorMember.id ?: throw invalidRequest(), + timezone = UTC_TIMEZONE, + pageable = PageRequest.of(page, size) + ), + contentId + ) } @Transactional fun create( characterId: Long, coverImage: MultipartFile, - audioFile: MultipartFile, + contentFile: MultipartFile, requestString: String - ): AiCharacterAdminAudioContentResponse { + ): AiCharacterAdminAudioContentCreateResponse { val target = targetResolver.resolve(characterId) - val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() - if (coverImage.isEmpty || audioFile.isEmpty) throw invalidRequest() + if (coverImage.isEmpty || contentFile.isEmpty) throw invalidRequest() val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java) - if (!request.isActive) throw invalidRequest() - validateSeriesIds(request.seriesIds, creatorMemberId) - val created = audioContentService.createAudioContent( - contentFile = audioFile, + val releaseDate = parseUtcReleaseDate(request.releaseDate) ?: LocalDateTime.now(ZoneOffset.UTC) + return audioContentService.createAudioContent( + contentFile = contentFile, coverImage = coverImage, - requestString = objectMapper.writeValueAsString( - CreateAudioContentRequest( - title = request.title, - detail = request.description, - tags = request.tags, - price = request.price, - purchaseOption = request.purchaseOption, - limited = request.limited, - releaseDate = request.releaseDateUtc?.toLegacyReleaseDate(), - timezone = UTC_TIMEZONE, - themeId = request.themeId, - isAdult = request.isAdult, - isGeneratePreview = request.isGeneratePreview, - isOnlyRental = request.isOnlyRental, - isPointAvailable = request.isPointAvailable, - isCommentAvailable = request.isCommentAvailable, - isFullDetailVisible = request.isFullDetailVisible, - previewStartTime = request.previewStartTime, - previewEndTime = request.previewEndTime, - languageCode = request.languageCode - ) - ), + request = request.toLegacyRequest(), + releaseDate = releaseDate, member = target.creatorMember ) - val content = repository.findByIdAndCreatorMemberId(created.contentId, creatorMemberId) ?: throw invalidRequest() - replaceSeriesIds(content, request.seriesIds, creatorMemberId) - - return mapper.toResponse(content, creatorMemberId) } @Transactional @@ -132,27 +183,21 @@ class AiCharacterAdminAudioContentFacade( characterId: Long, contentId: Long, coverImage: MultipartFile?, - audioFile: MultipartFile?, requestString: String - ): AiCharacterAdminAudioContentResponse { + ) { val target = targetResolver.resolve(characterId) val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest() - if (audioFile != null) throw invalidRequest() val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty } val request = readRequest(requestString, AiCharacterAdminAudioContentUpdateRequest::class.java) - request.seriesIds?.let { validateSeriesIds(it, creatorMemberId) } - if (request.isActive == false && request.releaseDateUtc != null) throw invalidRequest() - val hasReleaseDateUtc = hasField(requestString, "releaseDateUtc") - val releaseDateUtc = if (hasReleaseDateUtc) request.releaseDateUtc?.toUtcLocalDateTime() else null creatorAdminContentService.updateAudioContent( coverImage = normalizedCoverImage, requestString = objectMapper.writeValueAsString( - CreatorAdminAudioContentUpdatePayload( + UpdateCreatorAdminContentRequest( id = contentId, title = request.title, - detail = request.description, + detail = request.detail, tags = request.tags, price = request.price, isAdult = request.isAdult, @@ -163,60 +208,95 @@ class AiCharacterAdminAudioContentFacade( ), member = target.creatorMember ) - val updatedContent = repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest() - if (hasReleaseDateUtc) { - updatedContent.releaseDate = releaseDateUtc - if (releaseDateUtc?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) { - updatedContent.isActive = false - } - } - request.seriesIds?.let { replaceSeriesIds(updatedContent, it, creatorMemberId) } - - return mapper.toResponse(updatedContent, creatorMemberId) - } - - private fun replaceSeriesIds(content: AudioContent, seriesIds: List, creatorMemberId: Long) { - try { - repository.replaceSeriesIds(content, seriesIds, creatorMemberId) - } catch (_: IllegalArgumentException) { - throw invalidRequest() - } - } - - private fun validateSeriesIds(seriesIds: List, creatorMemberId: Long) { - if (!repository.hasActiveSeriesIds(seriesIds, creatorMemberId)) throw invalidRequest() - } - - private fun parseStatus(status: String?): AiCharacterAdminAudioContentStatus? { - return status?.let { - runCatching { AiCharacterAdminAudioContentStatus.valueOf(it) }.getOrElse { throw invalidRequest() } - } } private fun readRequest(requestString: String, requestClass: Class): T { return try { - objectMapper.readValue(requestString, requestClass) + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .readValue(requestString) } catch (_: JsonProcessingException) { throw invalidRequest() } } - private fun hasField(requestString: String, fieldName: String): Boolean { + private fun parseUtcReleaseDate(releaseDate: String?): LocalDateTime? { + if (releaseDate == null) return null + if (!releaseDate.endsWith("Z")) throw invalidRequest() return try { - objectMapper.readTree(requestString).has(fieldName) - } catch (_: JsonProcessingException) { + LocalDateTime.ofInstant(Instant.parse(releaseDate), ZoneOffset.UTC) + } catch (_: DateTimeException) { throw invalidRequest() } } - private fun String.toLegacyReleaseDate(): String { - return toUtcLocalDateTime().format(LEGACY_RELEASE_DATE_FORMATTER) + private fun resolveOwnedActiveContent(characterId: Long, contentId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + repository.findActiveByIdAndCreatorMemberId( + contentId = contentId, + creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + ) ?: throw invalidRequest() + return target } - private fun String.toUtcLocalDateTime(): LocalDateTime { + private fun validateCommentQuery(queryParameterNames: Set, page: Int, size: Int) { + if (!COMMENT_QUERY_PARAMETERS.containsAll(queryParameterNames) || page < 0 || size < MINIMUM_PAGE_SIZE) { + throw invalidRequest() + } + } + + private fun utcCommentDates( + response: GetAudioContentCommentListResponse, + contentId: Long + ): GetAudioContentCommentListResponse { + return response.copy(items = response.items.map { it.withUtcDate(contentId) }) + } + + private fun GetAudioContentCommentListItem.withUtcDate(contentId: Long): GetAudioContentCommentListItem { + val createdAt = repository.findCommentByIdAndContentId(id, contentId)?.createdAt ?: throw invalidRequest() + return copy(date = createdAt.toUtcIso()) + } + + private fun readCommentCreateRequest(requestString: String, contentId: Long): RegisterCommentRequest { + val serializedRequest = injectCommentPathId( + requestString = requestString, + allowedFields = CREATE_COMMENT_FIELDS, + idField = "contentId", + id = contentId + ) + return readRequest(serializedRequest, RegisterCommentRequest::class.java) + } + + private fun readCommentUpdateRequest(requestString: String, commentId: Long): ModifyCommentRequest { + val serializedRequest = injectCommentPathId( + requestString = requestString, + allowedFields = UPDATE_COMMENT_FIELDS, + idField = "commentId", + id = commentId + ) + val request = readRequest(serializedRequest, ModifyCommentRequest::class.java) + if (request.comment == null) throw invalidRequest() + return request + } + + private fun injectCommentPathId( + requestString: String, + allowedFields: Set, + idField: String, + id: Long + ): String { return try { - Instant.parse(this).atOffset(ZoneOffset.UTC).toLocalDateTime() - } catch (_: RuntimeException) { + val request = objectMapper.readTree(requestString) as? com.fasterxml.jackson.databind.node.ObjectNode + ?: throw invalidRequest() + if (request.fieldNames().asSequence().any { it !in allowedFields } || !request.hasNonNull("comment")) { + throw invalidRequest() + } + request.put(idField, id) + objectMapper.writeValueAsString(request) + } catch (_: JsonProcessingException) { throw invalidRequest() } } @@ -226,21 +306,10 @@ class AiCharacterAdminAudioContentFacade( } companion object { - private const val MINIMUM_PAGE_SIZE = 20 - private const val MAXIMUM_PAGE_SIZE = 50 + private const val MINIMUM_PAGE_SIZE = 1 private const val UTC_TIMEZONE = "UTC" - private val LEGACY_RELEASE_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + private val COMMENT_QUERY_PARAMETERS = setOf("page", "size") + private val CREATE_COMMENT_FIELDS = setOf("comment", "parentId", "isSecret", "languageCode") + private val UPDATE_COMMENT_FIELDS = setOf("comment") } - - private data class CreatorAdminAudioContentUpdatePayload( - val id: Long, - val title: String?, - val detail: String?, - val tags: String?, - val price: Int?, - val isAdult: Boolean?, - val isActive: Boolean?, - val isPointAvailable: Boolean?, - val isCommentAvailable: Boolean? - ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt index a2dea6bc..ee744732 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt @@ -2,58 +2,36 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentCreator import kr.co.vividnext.sodalive.content.PurchaseOption +import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import java.time.LocalDateTime import java.time.ZoneOffset -import java.time.format.DateTimeFormatter @Component class AiCharacterAdminAudioContentMapper( - private val repository: AiCharacterAdminAudioContentRepository, private val audioContentCloudFront: AudioContentCloudFront, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { - fun toListItem(content: AudioContent): AiCharacterAdminAudioContentListItem { - return AiCharacterAdminAudioContentListItem( - contentId = content.id ?: throw invalidRequest(), - title = content.title, - coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}", - audioSignedUrl = signedUrl(content), - price = content.price, - isAdult = content.isAdult, - isActive = content.isActive, - releaseDateUtc = content.releaseDate?.toUtcIso(), - status = status(content) - ) - } - - fun toResponse(content: AudioContent, creatorMemberId: Long): AiCharacterAdminAudioContentResponse { - val releaseDateUtc = content.releaseDate?.toUtcIso() + fun toResponse(content: AudioContent): AiCharacterAdminAudioContentResponse { val owner = content.member ?: throw invalidRequest() - val audioSignedUrl = signedUrl(content) return AiCharacterAdminAudioContentResponse( contentId = content.id ?: throw invalidRequest(), title = content.title, - description = content.detail, detail = content.detail, coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}", - audioSignedUrl = audioSignedUrl, - contentUrl = audioSignedUrl, + contentUrl = signedUrl(content).orEmpty(), languageCode = content.languageCode, themeStr = content.theme?.theme ?: "", tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "), price = content.price, duration = content.duration ?: "", isAdult = content.isAdult, - isActive = content.isActive, - isPointAvailable = content.isPointAvailable, - isCommentAvailable = content.isCommentAvailable, - releaseDateUtc = releaseDateUtc, - releaseDate = null, + releaseDate = releaseDate(content), totalContentCount = content.limited, remainingContentCount = content.remaining, orderSequence = null, @@ -66,13 +44,14 @@ class AiCharacterAdminAudioContentMapper( remainingTime = null, creatorOtherContentList = emptyList(), sameThemeOtherContentList = emptyList(), + isCommentAvailable = content.isCommentAvailable, isLike = false, likeCount = 0, commentList = emptyList(), commentCount = 0, isPin = false, isAvailablePin = false, - creator = AiCharacterAdminAudioContentCreatorResponse( + creator = AudioContentCreator( creatorId = owner.id ?: throw invalidRequest(), nickname = owner.nickname, profileImageUrl = if (owner.profileImage != null) { @@ -88,11 +67,7 @@ class AiCharacterAdminAudioContentMapper( nextContent = null, buyerList = emptyList(), isAvailableUsePoint = content.isPointAvailable, - translated = null, - status = status(content), - seriesIds = repository.findSeriesIds(content.id ?: throw invalidRequest(), creatorMemberId), - createdAtUtc = content.createdAt?.toUtcIso(), - updatedAtUtc = content.updatedAt?.toUtcIso() + translated = null ) } @@ -103,16 +78,10 @@ class AiCharacterAdminAudioContentMapper( return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime) } - private fun status(content: AudioContent): AiCharacterAdminAudioContentStatus { - return if (content.releaseDate?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) { - AiCharacterAdminAudioContentStatus.SCHEDULED - } else { - AiCharacterAdminAudioContentStatus.OPEN - } - } - - private fun LocalDateTime.toUtcIso(): String { - return atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT) + private fun releaseDate(content: AudioContent): String? { + return content.releaseDate + ?.takeIf { it > LocalDateTime.now(ZoneOffset.UTC) } + ?.toUtcIso() } private fun invalidRequest(): AiCharacterAdminApiException { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt index 37049f03..acd4b2ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt @@ -1,52 +1,17 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content -import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.QAudioContent.audioContent -import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series -import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent -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.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment import kr.co.vividnext.sodalive.member.QMember.member -import org.springframework.data.domain.Page -import org.springframework.data.domain.PageImpl -import org.springframework.data.domain.Pageable import org.springframework.stereotype.Repository -import java.time.LocalDateTime -import java.time.ZoneOffset -import javax.persistence.EntityManager @Repository class AiCharacterAdminAudioContentRepository( - private val queryFactory: JPAQueryFactory, - private val entityManager: EntityManager + private val queryFactory: JPAQueryFactory ) { - fun findPage( - creatorMemberId: Long, - search: String, - status: AiCharacterAdminAudioContentStatus?, - pageable: Pageable - ): Page { - val where = findWhere(creatorMemberId, search, status) - val contents = queryFactory - .selectFrom(audioContent) - .innerJoin(audioContent.member, member) - .where(where) - .offset(pageable.offset) - .limit(pageable.pageSize.toLong()) - .orderBy(audioContent.releaseDate.desc()) - .fetch() - val totalCount = queryFactory - .select(audioContent.count()) - .from(audioContent) - .innerJoin(audioContent.member, member) - .where(where) - .fetchOne() ?: 0L - - return PageImpl(contents, pageable, totalCount) - } - fun findByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? { return queryFactory .selectFrom(audioContent) @@ -55,89 +20,37 @@ class AiCharacterAdminAudioContentRepository( .fetchOne() } - fun findSeriesIds(contentId: Long, creatorMemberId: Long): List { + fun findActiveByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? { return queryFactory - .select(series.id) - .from(seriesContent) - .innerJoin(seriesContent.series, series) - .innerJoin(seriesContent.content, audioContent) + .selectFrom(audioContent) + .innerJoin(audioContent.member, member) .where( audioContent.id.eq(contentId) - .and(audioContent.member.id.eq(creatorMemberId)) - .and(series.member.id.eq(creatorMemberId)) - .and(series.isActive.isTrue) + .and(member.id.eq(creatorMemberId)) + .and(audioContent.isActive.isTrue) ) - .fetch() + .fetchOne() } - fun replaceSeriesIds(content: AudioContent, seriesIds: List, creatorMemberId: Long) { - val requestedIds = seriesIds.distinct() - val existingSeriesContents = findSeriesContents(content.id ?: throw IllegalArgumentException(), creatorMemberId) - val existingSeriesIds = existingSeriesContents.mapNotNull { it.series?.id }.toSet() - existingSeriesContents - .filter { it.series?.id !in requestedIds } - .forEach(entityManager::remove) - - if (requestedIds.isEmpty()) return - - val seriesList = findActiveSeriesByIds(requestedIds, creatorMemberId) - if (seriesList.size != requestedIds.size) throw IllegalArgumentException() - - seriesList.forEach { - if (it.id in existingSeriesIds) return@forEach - val seriesContent = SeriesContent() - seriesContent.series = it - seriesContent.content = content - entityManager.persist(seriesContent) - } - } - - private fun findSeriesContents(contentId: Long, creatorMemberId: Long): List { + fun findCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? { return queryFactory - .selectFrom(seriesContent) - .innerJoin(seriesContent.series, series) + .selectFrom(audioContentComment) .where( - seriesContent.content.id.eq(contentId) - .and(series.member.id.eq(creatorMemberId)) + audioContentComment.id.eq(commentId) + .and(audioContentComment.audioContent.id.eq(contentId)) ) - .fetch() + .fetchOne() } - fun hasActiveSeriesIds(seriesIds: List, creatorMemberId: Long): Boolean { - return seriesIds.isEmpty() || findActiveSeriesByIds(seriesIds, creatorMemberId).size == seriesIds.distinct().size - } - - private fun findActiveSeriesByIds(seriesIds: List, creatorMemberId: Long): List { + fun findActiveRootCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? { return queryFactory - .selectFrom(series) + .selectFrom(audioContentComment) .where( - series.id.`in`(seriesIds) - .and(series.member.id.eq(creatorMemberId)) - .and(series.isActive.isTrue) + audioContentComment.id.eq(commentId) + .and(audioContentComment.audioContent.id.eq(contentId)) + .and(audioContentComment.parent.isNull) + .and(audioContentComment.isActive.isTrue) ) - .fetch() - } - - private fun findWhere( - creatorMemberId: Long, - search: String, - status: AiCharacterAdminAudioContentStatus? - ): BooleanExpression { - val now = LocalDateTime.now(ZoneOffset.UTC) - var where = audioContent.duration.isNotNull - .and(audioContent.member.id.eq(creatorMemberId)) - .and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull)) - - if (status == AiCharacterAdminAudioContentStatus.SCHEDULED) { - where = where.and(audioContent.releaseDate.after(now)) - } else if (status == AiCharacterAdminAudioContentStatus.OPEN) { - where = where.and(audioContent.releaseDate.isNull.or(audioContent.releaseDate.loe(now))) - } - - if (search.length > 1) { - where = where.and(audioContent.title.contains(search).or(member.nickname.contains(search))) - } - - return where + .fetchOne() } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt index 360645c1..d30246b8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -30,7 +30,10 @@ class H2MysqlDateFunctions { .replace("%m", "MM") .replace("%d", "dd") .replace("%H", "HH") + .replace("%W", "EEEE") + .replace("%h", "hh") .replace("%i", "mm") + .replace("%p", "a") return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt new file mode 100644 index 00000000..e3fe921b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt @@ -0,0 +1,387 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.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.assertFalse +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.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +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.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.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.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentCommentTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @Test + @DisplayName("원댓글과 답글 목록은 timezone 없이 UTC Z date와 기존 totalCount 및 items를 반환한다") + fun shouldListRootCommentsAndRepliesWithUtcDates() { + registerMysqlDateFunctions() + val character = createCharacter("comment-list") + val content = saveAudioContent(character.creatorMember!!, "comment-list-content") + val root = saveComment(content, character.creatorMember!!, "root") + val reply = saveComment(content, character.creatorMember!!, "reply", root) + root.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 1) + reply.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 2) + entityManager.flush() + + mockMvc.perform( + get(commentsPath(character.id!!, content.id!!)) + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(root.id)) + .andExpect(jsonPath("$.data.items[0].comment").value("root")) + .andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:01Z")) + + mockMvc.perform( + get("${commentsPath(character.id!!, content.id!!)}/${root.id}/replies") + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].comment").value("reply")) + .andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:02Z")) + } + + @Test + @DisplayName("댓글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다") + fun shouldDefaultMissingCommentListPaginationParameters() { + registerMysqlDateFunctions() + val character = createCharacter("comment-default-page") + val content = saveAudioContent(character.creatorMember!!, "comment-default-page-content") + repeat(21) { index -> + saveComment(content, character.creatorMember!!, "root-$index") + } + entityManager.flush() + + val path = commentsPath(character.id!!, content.id!!) + mockMvc.perform(get(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(21)) + .andExpect(jsonPath("$.data.items.length()").value(20)) + + mockMvc.perform(get(path).param("page", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + + mockMvc.perform(get(path).param("size", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + } + + @Test + @DisplayName("답글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다") + fun shouldDefaultMissingReplyListPaginationParameters() { + registerMysqlDateFunctions() + val character = createCharacter("reply-default-page") + val content = saveAudioContent(character.creatorMember!!, "reply-default-page-content") + val root = saveComment(content, character.creatorMember!!, "root") + repeat(21) { index -> + saveComment(content, character.creatorMember!!, "reply-$index", root) + } + entityManager.flush() + + val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}/replies" + mockMvc.perform(get(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(21)) + .andExpect(jsonPath("$.data.items.length()").value(20)) + + mockMvc.perform(get(path).param("page", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + + mockMvc.perform(get(path).param("size", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + } + + @Test + @DisplayName("작성은 target AI를 writer로 사용하고 root와 답글 모두 data null을 반환한다") + fun shouldCreateRootAndReplyAsTargetAi() { + val character = createCharacter("comment-create") + val content = saveAudioContent(character.creatorMember!!, "comment-create-content") + entityManager.flush() + + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"root", "languageCode":"ko"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val root = commentsFor(content.id!!).single() + assertEquals(character.creatorMember!!.id, root.member!!.id) + + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"reply", "parentId":${root.id}, "isSecret":false, "languageCode":"ko"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val reply = commentsFor(content.id!!).single { it.parent?.id == root.id } + assertEquals(character.creatorMember!!.id, reply.member!!.id) + } + + @Test + @DisplayName("다른 콘텐츠 또는 비활성 parent 답글 작성은 insert 없이 거부한다") + fun shouldRejectInvalidReplyParentWithoutInsert() { + val character = createCharacter("comment-parent") + val content = saveAudioContent(character.creatorMember!!, "comment-parent-content") + val otherContent = saveAudioContent(character.creatorMember!!, "comment-other-content") + val foreignParent = saveComment(otherContent, character.creatorMember!!, "other root") + val inactiveParent = saveComment(content, character.creatorMember!!, "inactive root").apply { isActive = false } + entityManager.flush() + + val beforeCount = commentsFor(content.id!!).size + listOf(foreignParent.id!!, inactiveParent.id!!).forEach { parentId -> + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"must not persist", "parentId":$parentId}""") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + assertEquals(beforeCount, commentsFor(content.id!!).size) + } + + @Test + @DisplayName("target AI가 작성하지 않은 활성 댓글 수정은 거부하고 원문을 유지한다") + fun shouldRejectFanAuthoredCommentUpdate() { + val character = createCharacter("comment-update") + val content = saveAudioContent(character.creatorMember!!, "comment-update-content") + val fanComment = saveComment(content, saveMember("comment-update-fan"), "fan comment") + entityManager.flush() + + mockMvc.perform( + put("${commentsPath(character.id!!, content.id!!)}/${fanComment.id}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"changed"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals("fan comment", entityManager.find(AudioContentComment::class.java, fanComment.id).comment) + } + + @Test + @DisplayName("target AI가 작성한 활성 댓글 수정은 data null로 완료한다") + fun shouldUpdateTargetAiAuthoredComment() { + val character = createCharacter("comment-update-target") + val content = saveAudioContent(character.creatorMember!!, "comment-update-target-content") + val comment = saveComment(content, character.creatorMember!!, "before") + entityManager.flush() + + mockMvc.perform( + put("${commentsPath(character.id!!, content.id!!)}/${comment.id}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"after"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + assertEquals("after", entityManager.find(AudioContentComment::class.java, comment.id).comment) + } + + @Test + @DisplayName("삭제는 대상 콘텐츠의 한 row만 비활성화하고 이미 비활성이면 성공 no-op이다") + fun shouldSoftDeleteOnlyTargetRowAndIgnoreInactiveComment() { + val character = createCharacter("comment-delete") + val content = saveAudioContent(character.creatorMember!!, "comment-delete-content") + val fan = saveMember("comment-delete-fan") + val root = saveComment(content, fan, "root") + val reply = saveComment(content, fan, "reply", root) + entityManager.flush() + + val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}" + mockMvc.perform(delete(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertFalse(entityManager.find(AudioContentComment::class.java, root.id).isActive) + assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive) + + mockMvc.perform(delete(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive) + } + + @Test + @DisplayName("잘못된 요청은 공통 400 envelope으로 변환한다") + fun shouldRejectInvalidCommentRequests() { + val character = createCharacter("comment-request") + val content = saveAudioContent(character.creatorMember!!, "comment-request-content") + entityManager.flush() + + listOf("{}", "{\"comment\":", "{\"comment\":\"comment\",\"unknown\":true}").forEach { body -> + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + mockMvc.perform( + get(commentsPath(character.id!!, content.id!!)) + .param("page", "-1") + .param("size", "0") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + mockMvc.perform( + get(commentsPath(character.id!!, content.id!!)) + .param("timezone", "UTC") + .param("page", "0") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-$name-character", + name = name, + description = "description", + systemPrompt = "prompt" + ).also { + it.creatorMember!!.profileImage = "profile/$name.png" + } + + private fun saveAudioContent(owner: Member, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = 100, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + entityManager.persist(this) + } + } + + private fun saveComment( + content: AudioContent, + writer: Member, + comment: String, + parent: AudioContentComment? = null + ): AudioContentComment { + return AudioContentComment(comment = comment, languageCode = "ko").apply { + audioContent = content + member = writer + this.parent = parent + entityManager.persist(this) + } + } + + private fun saveMember(nickname: String): Member { + return Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.USER + ).apply(entityManager::persist) + } + + private fun commentsFor(contentId: Long): List { + entityManager.flush() + return entityManager.createQuery( + "select comment from AudioContentComment comment where comment.audioContent.id = :contentId", + AudioContentComment::class.java + ).setParameter("contentId", contentId).resultList + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + + private fun commentsPath(characterId: Long, contentId: Long): String { + return "/api/v2/admin/ai-characters/$characterId/audio-contents/$contentId/comments" + } + + 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/content/AiCharacterAdminAudioContentControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt index 0ef7132c..d036f39f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt @@ -24,6 +24,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -46,6 +47,7 @@ import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @@ -80,6 +82,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( @BeforeEach fun replaceActualServicePublishers() { + registerMysqlDateFunctions() originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher) originalCreatorAdminContentServicePublisher = replacePublisher( creatorAdminContentService, @@ -94,8 +97,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } @Test - @DisplayName("생성은 themeId, 예약일, seriesIds를 포함한 multipart 요청으로 target 소유 콘텐츠 상세를 반환한다") - fun shouldCreateOwnedContentWithThemeIdReleaseDateAndSeriesIds() { + @DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다") + fun shouldCreateOwnedContentWithUtcMultipartRequest() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-create-character", name = "v2-audio-create-character", @@ -104,7 +107,6 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( ) val theme = AudioContentTheme(theme = "create-theme", image = "theme.png") entityManager.persist(theme) - val series = saveSeries("create-series", character.creatorMember!!) entityManager.flush() Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) .thenReturn(URL("https://s3.example.com/test")) @@ -112,14 +114,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", "request.json", MediaType.APPLICATION_JSON_VALUE, """ - {"title":"created audio","description":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"isAdult":false,"isActive":true,"themeId":${theme.id},"releaseDateUtc":"2026-07-25T00:00:00Z","seriesIds":[${series.id}],"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"} + {"title":"created audio","detail":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"releaseDate":"2027-07-30T10:00:00Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"} """.trimIndent().toByteArray() ) ) @@ -127,28 +129,13 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.title").value("created audio")) - .andExpect(jsonPath("$.data.description").value("created detail")) - .andExpect(jsonPath("$.data.languageCode").value("en")) - .andExpect(jsonPath("$.data.tag").value("#calm #night")) - .andExpect(jsonPath("$.data.price").value(100)) - .andExpect(jsonPath("$.data.totalContentCount").value(7)) - .andExpect(jsonPath("$.data.remainingContentCount").value(7)) - .andExpect(jsonPath("$.data.isActivePreview").value(true)) - .andExpect(jsonPath("$.data.isOnlyRental").value(true)) - .andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY")) - .andExpect(jsonPath("$.data.isActive").value(false)) - .andExpect(jsonPath("$.data.isPointAvailable").value(true)) - .andExpect(jsonPath("$.data.isCommentAvailable").value(false)) - .andExpect(jsonPath("$.data.isAvailableUsePoint").value(true)) - .andExpect(jsonPath("$.data.releaseDateUtc").value("2026-07-25T00:00:00Z")) - .andExpect(jsonPath("$.data.seriesIds[0]").value(series.id)) - .andExpect(jsonPath("$.data.content").doesNotExist()) + .andExpect(jsonPath("$.data.contentId").isNumber) + .andExpect(jsonPath("$.data.title").doesNotExist()) } @Test - @DisplayName("수정은 seriesIds를 target 소유 시리즈로 교체하고 상세를 반환한다") - fun shouldReplaceOwnedContentSeriesIds() { + @DisplayName("수정은 text/plain request part를 415와 부작용 없음으로 거부한다") + fun shouldRejectTextPlainRequestPartBeforeSideEffects() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-series-update-character", name = "v2-audio-series-update-character", @@ -156,7 +143,6 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( systemPrompt = "prompt" ) val oldSeries = saveSeries("old-series", character.creatorMember!!) - val newSeries = saveSeries("new-series", character.creatorMember!!) val content = saveAudioContent( owner = character.creatorMember!!, title = "series update title", @@ -164,6 +150,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( ) saveSeriesContent(oldSeries, content) entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") @@ -172,15 +160,19 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( "request", "request.json", MediaType.TEXT_PLAIN_VALUE, - """{"seriesIds":[${newSeries.id}]}""".toByteArray() + """{"title":"series update title","detail":"updated detail"}""".toByteArray() ) ) .with(adminAuthentication()) ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.seriesIds.length()").value(1)) - .andExpect(jsonPath("$.data.seriesIds[0]").value(newSeries.id)) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("series update title", entityManager.find(AudioContent::class.java, content.id).title) + assertEquals(oldSeries.id, linkedSeriesId(content.id!!)) + } } @ParameterizedTest @@ -189,8 +181,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( "en,Invalid request.", "ja,無効なリクエストです。" ) - @DisplayName("생성은 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다") - fun shouldRejectCreateWithOtherCharacterSeriesIdsBeforeUpload(language: String, message: String) { + @DisplayName("생성은 계약에 없는 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다") + fun shouldRejectCreateWithUnsupportedSeriesIdsBeforeUpload(language: String, message: String) { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-create-series-target", name = "v2-audio-create-series-target", @@ -213,14 +205,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", "request.json", MediaType.APPLICATION_JSON_VALUE, """ - {"title":"invalid series audio","description":"created detail","price":100,"isAdult":false,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]} + {"title":"invalid series audio","detail":"created detail","tags":"","price":100,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]} """.trimIndent().toByteArray() ) ) @@ -233,8 +225,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } @Test - @DisplayName("생성은 처리 완료 전 유지할 수 없는 isActive false를 거부한다") - fun shouldRejectCreateWithInactiveRequest() { + @DisplayName("생성은 레거시 계약에 없는 isActive 필드를 거부한다") + fun shouldRejectCreateWithUnsupportedIsActiveField() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-create-inactive-character", name = "v2-audio-create-inactive-character", @@ -248,14 +240,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", "request.json", MediaType.APPLICATION_JSON_VALUE, """ - {"title":"inactive audio","description":"created detail","price":100,"isAdult":false,"isActive":false,"themeId":${theme.id},"seriesIds":[]} + {"title":"inactive audio","detail":"created detail","tags":"","price":100,"isActive":false,"themeId":${theme.id}} """.trimIndent().toByteArray() ) ) @@ -283,14 +275,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", "request.json", MediaType.APPLICATION_JSON_VALUE, """ - {"title":"invalid price audio","description":"created detail","price":1,"isAdult":false,"themeId":${theme.id},"seriesIds":[]} + {"title":"invalid price audio","detail":"created detail","tags":"","price":1,"themeId":${theme.id}} """.trimIndent().toByteArray() ) ) @@ -323,7 +315,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"isActive":false,"releaseDateUtc":"2026-07-25T00:00:00Z"}""".toByteArray() ) ) @@ -336,8 +328,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } @Test - @DisplayName("AI 캐릭터 콘텐츠 목록은 target 소유 공개 콘텐츠만 signed URL과 보정된 페이지로 반환한다") - fun shouldListTargetContentsWithSignedUrlAndNormalizedPagination() { + @DisplayName("AI 캐릭터 콘텐츠 목록은 search_word와 레거시 signed URL item을 반환한다") + fun shouldListTargetContentsWithLegacySearchAndSignedUrl() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-list-character", name = "v2-audio-list-character", @@ -372,25 +364,22 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( get("/api/v2/admin/ai-characters/${character.id}/audio-contents") - .param("search", "night") - .param("status", "OPEN") - .param("page", "-1") + .param("search_word", "target night") + .param("page", "0") .param("size", "100") .with(adminAuthentication()) ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.page").value(0)) - .andExpect(jsonPath("$.data.size").value(50)) .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.items[0].contentId").value(content.id)) + .andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id)) .andExpect(jsonPath("$.data.items[0].title").value("target night walk")) .andExpect( - jsonPath("$.data.items[0].audioSignedUrl").value( + jsonPath("$.data.items[0].contentUrl").value( "https://signed.example.com/private/target-night-walk.mp3?Expires=1" ) ) - .andExpect(jsonPath("$.data.items[0].content").doesNotExist()) + .andExpect(jsonPath("$.data.page").doesNotExist()) Mockito.verify(audioContentCloudFront).generateSignedURL( "private/target-night-walk.mp3", @@ -431,8 +420,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } @Test - @DisplayName("status SCHEDULED 목록은 target 소유 예약 콘텐츠만 반환한다") - fun shouldListScheduledContentsWhenStatusIsScheduled() { + @DisplayName("search_word 목록은 target 소유 예약 콘텐츠도 검색한다") + fun shouldSearchScheduledOwnedContents() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-list-scheduled-status-character", name = "v2-audio-list-scheduled-status-character", @@ -456,18 +445,17 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( get("/api/v2/admin/ai-characters/${character.id}/audio-contents") - .param("status", "SCHEDULED") + .param("search_word", "scheduled explicit") .with(adminAuthentication()) ) .andExpect(status().isOk) .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.items[0].contentId").value(scheduled.id)) - .andExpect(jsonPath("$.data.items[0].status").value("SCHEDULED")) + .andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id)) } @Test - @DisplayName("status SCHEDULED 목록은 UTC 기준 미래 예약 콘텐츠를 서버 시간대와 무관하게 반환한다") - fun shouldListUtcFutureScheduledContentsWhenServerTimezoneIsAheadOfUtc() { + @DisplayName("목록은 UTC 미래 예약 콘텐츠의 레거시 releaseDate를 반환한다") + fun shouldReturnLegacyReleaseDateForUtcFutureScheduledContent() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-list-utc-scheduled-character", name = "v2-audio-list-utc-scheduled-character", @@ -486,13 +474,13 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( get("/api/v2/admin/ai-characters/${character.id}/audio-contents") - .param("status", "SCHEDULED") + .param("search_word", "utc future") .with(adminAuthentication()) ) .andExpect(status().isOk) .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.items[0].contentId").value(scheduled.id)) - .andExpect(jsonPath("$.data.items[0].status").value("SCHEDULED")) + .andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id)) + .andExpect(jsonPath("$.data.items[0].releaseDate").isString) } @Test @@ -520,13 +508,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.contentId").value(content.id)) - .andExpect(jsonPath("$.data.description").value("detail")) .andExpect(jsonPath("$.data.detail").value("detail")) .andExpect(jsonPath("$.data.languageCode").value("ko")) .andExpect(jsonPath("$.data.themeStr").value("theme-detail night walk")) .andExpect(jsonPath("$.data.tag").value("")) .andExpect(jsonPath("$.data.duration").value("01:10:00")) - .andExpect(jsonPath("$.data.releaseDate").doesNotExist()) + .andExpect(jsonPath("$.data.releaseDate").value(nullValue())) .andExpect(jsonPath("$.data.totalContentCount").doesNotExist()) .andExpect(jsonPath("$.data.remainingContentCount").doesNotExist()) .andExpect(jsonPath("$.data.orderSequence").doesNotExist()) @@ -539,6 +526,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.remainingTime").doesNotExist()) .andExpect(jsonPath("$.data.creatorOtherContentList").isEmpty) .andExpect(jsonPath("$.data.sameThemeOtherContentList").isEmpty) + .andExpect(jsonPath("$.data.isCommentAvailable").value(true)) .andExpect(jsonPath("$.data.isLike").value(false)) .andExpect(jsonPath("$.data.likeCount").value(0)) .andExpect(jsonPath("$.data.commentList").isEmpty) @@ -551,17 +539,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.buyerList").isEmpty) .andExpect(jsonPath("$.data.isAvailableUsePoint").value(true)) .andExpect(jsonPath("$.data.translated").doesNotExist()) - .andExpect(jsonPath("$.data.seriesIds").isEmpty) - .andExpect( - jsonPath("$.data.audioSignedUrl").value( - "https://signed.example.com/private/detail-night-walk.mp3?Expires=1" - ) - ) .andExpect( jsonPath("$.data.contentUrl").value( "https://signed.example.com/private/detail-night-walk.mp3?Expires=1" ) ) + .andExpect(jsonPath("$.data.audioSignedUrl").doesNotExist()) .andExpect(jsonPath("$.data.content").doesNotExist()) Mockito.verify(audioContentCloudFront).generateSignedURL( @@ -646,7 +629,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"title":"other after title"}""".toByteArray() ) ) @@ -699,7 +682,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"seriesIds":[${otherSeries.id}]}""".toByteArray() ) ) @@ -714,19 +697,20 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } } - @Test - @DisplayName("수정은 audioFile 교체를 거부한다") - fun shouldRejectAudioFileUpdate() { + @ParameterizedTest + @ValueSource(strings = ["audioFile", "contentFile"]) + @DisplayName("수정은 audioFile과 contentFile 교체를 거부한다") + fun shouldRejectUnsupportedContentFileUpdate(filePart: String) { val character = chatCharacterService.createChatCharacterWithDetails( - characterUUID = "v2-audio-update-audio-file-character", - name = "v2-audio-update-audio-file-character", + characterUUID = "v2-audio-update-$filePart-character", + name = "v2-audio-update-$filePart-character", description = "description", systemPrompt = "prompt" ) val content = saveAudioContent( owner = character.creatorMember!!, - title = "audio file update title", - contentPath = "private/audio-file-update.mp3" + title = "$filePart update title", + contentPath = "private/$filePart-update.mp3" ) entityManager.flush() val beforeAudioContents = countAudioContents() @@ -734,12 +718,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile(filePart, "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray() ) ) @@ -749,7 +733,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(false)) assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { - assertEquals("audio file update title", entityManager.find(AudioContent::class.java, content.id).title) + assertEquals("$filePart update title", entityManager.find(AudioContent::class.java, content.id).title) } } @@ -784,7 +768,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"releaseDateUtc":"not-a-date"}""".toByteArray() ) ) @@ -801,8 +785,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } @Test - @DisplayName("수정은 활성 콘텐츠를 미래 예약으로 변경할 때 비활성화해 조기 공개를 막는다") - fun shouldDeactivateContentWhenUpdatingFutureReleaseDateUtc() { + @DisplayName("수정은 레거시 계약에 없는 releaseDateUtc를 거부한다") + fun shouldRejectUnsupportedFutureReleaseDateUtc() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-update-future-release-character", name = "v2-audio-update-future-release-character", @@ -825,22 +809,21 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"releaseDateUtc":"$releaseDateUtc"}""".toByteArray() ) ) .with(adminAuthentication()) ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.data.isActive").value(false)) - .andExpect(jsonPath("$.data.status").value("SCHEDULED")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) - assertEquals(false, entityManager.find(AudioContent::class.java, content.id).isActive) + assertEquals(true, entityManager.find(AudioContent::class.java, content.id).isActive) } @Test - @DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 상세를 반환한다") - fun shouldSoftDeleteOwnedContentAndReturnDetail() { + @DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 data null을 반환한다") + fun shouldSoftDeleteOwnedContentAndReturnNull() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-update-character", name = "v2-audio-update-character", @@ -860,7 +843,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"title":"after title","isActive":false}""".toByteArray() ) ) @@ -868,9 +851,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.title").value("after title")) - .andExpect(jsonPath("$.data.isActive").value(false)) - .andExpect(jsonPath("$.data.releaseDateUtc").doesNotExist()) + .andExpect(jsonPath("$.data").value(nullValue())) + + val updated = entityManager.find(AudioContent::class.java, content.id) + assertEquals("after title", updated.title) + assertEquals(false, updated.isActive) + assertEquals(null, updated.releaseDate) } @Test @@ -895,8 +881,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .with(adminAuthentication()) ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.page").value(0)) - .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.page").doesNotExist()) + .andExpect(jsonPath("$.data.size").doesNotExist()) mockMvc.perform( get("/api/v2/admin/ai-characters/${character.id}/audio-contents") @@ -904,7 +890,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .with(adminAuthentication()) ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.size").doesNotExist()) } private fun saveAudioContent( @@ -1013,6 +999,15 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( .toLong() } + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + private fun adminAuthentication() = authentication( UsernamePasswordAuthenticationToken( MemberAdapter( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt index d82eb9db..ee40bf3e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content import com.amazonaws.services.s3.AmazonS3Client import com.amazonaws.services.s3.model.PutObjectRequest import com.amazonaws.services.s3.model.PutObjectResult +import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContentService @@ -15,11 +16,14 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull 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 @@ -29,6 +33,7 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpHeaders 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 @@ -38,6 +43,7 @@ import org.springframework.test.util.ReflectionTestUtils import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.RequestBuilder 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.Propagation @@ -55,6 +61,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( private val mockMvc: MockMvc, private val chatCharacterService: ChatCharacterService, private val audioContentService: AudioContentService, + private val objectMapper: ObjectMapper, private val transactionTemplate: TransactionTemplate, private val entityManager: EntityManager ) { @@ -94,7 +101,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( assertMissingPartRequest( requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(requestPart(theme.id!!)) .header(HttpHeaders.ACCEPT_LANGUAGE, language) .with(adminAuthentication()), @@ -107,9 +114,9 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( "coverImage,ko,잘못된 요청입니다.", "coverImage,en,Invalid request.", "coverImage,ja,無効なリクエストです。", - "audioFile,ko,잘못된 요청입니다.", - "audioFile,en,Invalid request.", - "audioFile,ja,無効なリクエストです。" + "contentFile,ko,잘못된 요청입니다.", + "contentFile,en,Invalid request.", + "contentFile,ja,無効なリクエストです。" ) @DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다") fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) { @@ -121,12 +128,12 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( ) val theme = saveTheme() val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1) - val audioBytes = if (filePart == "audioFile") byteArrayOf() else byteArrayOf(1) + val contentBytes = if (filePart == "contentFile") byteArrayOf() else byteArrayOf(1) mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", coverBytes)) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", audioBytes)) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", contentBytes)) .file(requestPart(theme.id!!)) .header(HttpHeaders.ACCEPT_LANGUAGE, language) .with(adminAuthentication()) @@ -147,8 +154,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( "en,Invalid request.", "ja,無効なリクエストです。" ) - @DisplayName("생성은 audioFile part 누락을 업로드 전 invalid request로 거부한다") - fun shouldRejectMissingAudioFileBeforeUpload(language: String, message: String) { + @DisplayName("생성은 contentFile part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingContentFileBeforeUpload(language: String, message: String) { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-create-missing-audio-character", name = "v2-audio-create-missing-audio-character", @@ -185,13 +192,132 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( assertMissingPartRequest( requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .header(HttpHeaders.ACCEPT_LANGUAGE, language) .with(adminAuthentication()), message = message ) } + @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 shouldRejectNonJsonRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language", + name = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!, 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)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 미정의 multipart part를 업로드 전 invalid request로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-undefined-part-$language", + name = "v2-audio-create-undefined-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 filename 없는 미정의 multipart part를 업로드 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-filename-less-part-$language", + name = "v2-audio-create-filename-less-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .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)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + @Test @DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다") fun shouldRejectMissingThemeBeforeUpload() { @@ -205,7 +331,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(requestPart(999999L)) .header(HttpHeaders.ACCEPT_LANGUAGE, "en") .with(adminAuthentication()) @@ -219,6 +345,166 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( assertEquals(0L, countAudioContents()) } + @ParameterizedTest + @CsvSource( + value = [ + "onlyStart|ko|미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.", + "onlyStart|en|You must enter both preview start and end times, or neither.", + "onlyStart|ja|プレビューの開始時間と終了時間は両方入力するか、両方入力しないでください。", + "malformed|ko|미리 듣기 시간 형식은 00:30:00 과 같아야 합니다", + "malformed|en|Preview time format must be like 00:30:00.", + "malformed|ja|プレビュー時間の形式は00:30:00のようにする必要があります。", + "tooShort|ko|미리 듣기의 최소 시간은 15초 입니다.", + "tooShort|en|The minimum preview time is 15 seconds.", + "tooShort|ja|プレビューの最小時間は15秒です。" + ], + delimiter = '|' + ) + @DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다") + fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-preview-$previewCase-$language", + name = "v2-audio-create-preview-$previewCase-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val previewFields = when (previewCase) { + "onlyStart" -> ",\"previewStartTime\":\"00:00:01\"" + "malformed" -> ",\"previewStartTime\":\"00:1:01\",\"previewEndTime\":\"00:00:20\"" + else -> ",\"previewStartTime\":\"00:00:01\",\"previewEndTime\":\"00:00:10\"" + } + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id}$previewFields} + """.trimIndent().toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @Test + @DisplayName("생성은 timezone request field를 업로드 전 invalid request로 거부한다") + fun shouldRejectTimezoneRequestFieldBeforeUpload() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-timezone-field-character", + name = "v2-audio-create-timezone-field-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"2027-07-30 10:00","timezone":"UTC"} + """ + ) + } + + @ParameterizedTest + @ValueSource(strings = ["2027-07-30 10:00", "2027-07-30T10:00:00+09:00", "2027-07-30T10:00:00+00:00"]) + @DisplayName("생성은 local 또는 offset releaseDate를 업로드 전 invalid request로 거부한다") + fun shouldRejectNonUtcReleaseDateBeforeUpload(releaseDate: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}", + name = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"$releaseDate"} + """ + ) + } + + @ParameterizedTest + @ValueSource(strings = ["missing", "null"]) + @DisplayName("생성은 price 누락과 null을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingOrNullPriceBeforeUpload(priceCase: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-$priceCase-price-character", + name = "v2-audio-create-$priceCase-price-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val priceJson = if (priceCase == "null") ",\"price\":null" else "" + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"","themeId":${theme.id}$priceJson} + """ + ) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "themeId", + "isAdult", + "isGeneratePreview", + "isOnlyRental", + "isPointAvailable", + "isCommentAvailable", + "isFullDetailVisible" + ] + ) + @DisplayName("생성은 non-null primitive null을 업로드 전 invalid request로 거부한다") + fun shouldRejectNullPrimitiveFieldsBeforeUpload(field: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-null-$field-character", + name = "v2-audio-create-null-$field-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val primitiveFields = listOf( + "price" to "100", + "themeId" to theme.id.toString(), + "isAdult" to "false", + "isGeneratePreview" to "false", + "isOnlyRental" to "false", + "isPointAvailable" to "false", + "isCommentAvailable" to "false", + "isFullDetailVisible" to "true" + ).joinToString(",") { (key, value) -> + val fieldValue = if (key == field) "null" else value + "\"$key\":$fieldValue" + } + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"",$primitiveFields} + """ + ) + } + @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다") @@ -237,7 +523,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(requestPart(theme.id!!)) .header(HttpHeaders.ACCEPT_LANGUAGE, "en") .with(adminAuthentication()) @@ -271,7 +557,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(requestPart(theme.id!!)) .header(HttpHeaders.ACCEPT_LANGUAGE, "en") .with(adminAuthentication()) @@ -307,7 +593,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(requestPart(theme.id!!)) .header(HttpHeaders.ACCEPT_LANGUAGE, "en") .with(adminAuthentication()) @@ -325,8 +611,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( } @Test - @DisplayName("생성은 tags 누락과 isActive true를 legacy pipeline 기본 계약으로 허용한다") - fun shouldAllowMissingTagsAndActiveTrueRequest() { + @DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다") + fun shouldCreateWithUtcReleaseDateAndReturnContentId() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-create-minimal-character", name = "v2-audio-create-minimal-character", @@ -337,26 +623,74 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) .thenReturn(URL("https://test.cloudfront.net/uploaded")) - mockMvc.perform( + val response = mockMvc.perform( multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", "request.json", MediaType.APPLICATION_JSON_VALUE, """ - {"title":"minimal audio","description":"detail","price":100,"themeId":${theme.id},"isActive":true} + {"title":"utc audio","detail":"utc detail","tags":"#utc","price":100,"purchaseOption":"BOTH","limited":7,"releaseDate":"2027-07-30T10:00:01Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":false,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"ko"} """.trimIndent().toByteArray() ) ) .with(adminAuthentication()) ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.title").value("minimal audio")) - .andExpect(jsonPath("$.data.tag").value("")) - .andExpect(jsonPath("$.data.isActive").value(false)) + .andExpect(jsonPath("$.data.contentId").isNumber) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + assertEquals(setOf("contentId"), data.fieldNames().asSequence().toSet()) + val content = findAudioContentByTitle("utc audio")!! + assertEquals(data.path("contentId").asLong(), content.id) + assertEquals("utc detail", content.detail) + assertEquals(7, content.limited) + assertEquals("2027-07-30T10:00:01", content.releaseDate.toString()) + + val uploadCaptor = ArgumentCaptor.forClass(PutObjectRequest::class.java) + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(uploadCaptor.capture()) + val audioUpload = uploadCaptor.allValues.single { it.key.startsWith("input/") } + assertEquals("00:00:05", audioUpload.metadata.userMetadata["preview_start_time"]) + assertEquals("00:00:25", audioUpload.metadata.userMetadata["preview_end_time"]) + assertTrue(audioUpload.metadata.userMetadata["generate_preview"].toBoolean()) + } + + @ParameterizedTest + @ValueSource(strings = ["", ",\"releaseDate\":null"]) + @DisplayName("생성은 생략 또는 null releaseDate를 허용한다") + fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}", + name = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"nullable release","detail":"detail","tags":"","price":100,"themeId":${theme.id}$releaseDateField} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentId").isNumber) } private fun saveTheme(): AudioContentTheme { @@ -366,13 +700,16 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( return theme } - private fun requestPart(themeId: Long): MockMultipartFile { + private fun requestPart( + themeId: Long, + contentType: String? = MediaType.APPLICATION_JSON_VALUE + ): MockMultipartFile { return MockMultipartFile( "request", "request.json", - MediaType.APPLICATION_JSON_VALUE, + contentType, """ - {"title":"audio","description":"detail","price":100,"themeId":$themeId,"isActive":true} + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":$themeId} """.trimIndent().toByteArray() ) } @@ -394,6 +731,30 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( assertEquals(0L, countAudioContents()) } + private fun assertInvalidCreateRequestBeforeSideEffects(characterId: Long, request: String) { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/$characterId/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + request.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + private fun countAudioContents(): Long { return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) .singleResult diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt index 5c3a055f..8f47bf2a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt @@ -133,7 +133,7 @@ class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor( val result = mockMvc.perform( multipart("/api/v2/admin/ai-characters/999999/audio-contents") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file( MockMultipartFile( "request", @@ -205,7 +205,7 @@ class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor( val result = mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/999999/audio-contents/1") - .file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) .header(HttpHeaders.ACCEPT_LANGUAGE, language) .with(adminAuthentication()) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt index 16d54b27..35645d33 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt @@ -1,15 +1,18 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content +import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse import kr.co.vividnext.sodalive.content.PurchaseOption import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer -import org.junit.jupiter.api.Assertions.assertFalse +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.mockito.Mockito @@ -36,14 +39,85 @@ import javax.persistence.EntityManager class AiCharacterAdminAudioContentQueryTest @Autowired constructor( private val mockMvc: MockMvc, private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, private val entityManager: EntityManager ) { @MockBean private lateinit var audioContentCloudFront: AudioContentCloudFront @Test - @DisplayName("상세는 과거 공개일을 releaseDateUtc에만 반환하고 legacy releaseDate는 비운다") - fun shouldExposePastReleaseDateOnlyAsUtcField() { + @DisplayName("목록은 search_word로 검색하고 레거시 전체 item exact 필드를 반환한다") + fun shouldSearchByLegacyQueryAndReturnFullLegacyListItem() { + registerMysqlDateFunctions() + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-list-character", + name = "v2-audio-query-list-character", + description = "description", + systemPrompt = "prompt" + ) + val matching = saveAudioContent( + owner = character.creatorMember!!, + title = "legacy target audio", + releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0) + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "different audio", + releaseDate = LocalDateTime.of(2026, 7, 24, 0, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("search_word", "legacy target") + .param("page", "0") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + val item = data.path("items").path(0) + assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet()) + assertEquals( + setOf( + "audioContentId", + "title", + "detail", + "coverImageUrl", + "creatorNickname", + "theme", + "price", + "totalContentCount", + "remainingContentCount", + "isAdult", + "isPointAvailable", + "isCommentAvailable", + "remainingTime", + "contentUrl", + "date", + "releaseDate", + "tags" + ), + item.fieldNames().asSequence().toSet() + ) + assertEquals(matching.id, item.path("audioContentId").asLong()) + assertEquals("legacy target audio", item.path("title").asText()) + assertEquals("detail", item.path("detail").asText()) + assertEquals("https://test.cloudfront.net/cover/legacy target audio.png", item.path("coverImageUrl").asText()) + assertEquals( + "https://signed.example.com/private/legacy target audio.mp3?Expires=1", + item.path("contentUrl").asText() + ) + } + + @Test + @DisplayName("상세는 timezone 없이 기존 nested DTO exact 필드를 반환한다") + fun shouldReturnFullLegacyDetailContractWithoutTimezone() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-query-release-character", name = "v2-audio-query-release-character", @@ -59,42 +133,150 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor( Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) .thenReturn("https://signed.example.com/query-release.mp3?Expires=1") + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/query-release.mp3?Expires=1")) + .andExpect(jsonPath("$.data.isOnlyRental").value(true)) + .andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY")) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + assertEquals( + setOf( + "contentId", + "title", + "detail", + "languageCode", + "coverImageUrl", + "contentUrl", + "themeStr", + "tag", + "price", + "duration", + "releaseDate", + "totalContentCount", + "remainingContentCount", + "orderSequence", + "isActivePreview", + "isAdult", + "isMosaic", + "isOnlyRental", + "existOrdered", + "purchaseOption", + "orderType", + "remainingTime", + "creatorOtherContentList", + "sameThemeOtherContentList", + "isCommentAvailable", + "isLike", + "likeCount", + "commentList", + "commentCount", + "isPin", + "isAvailablePin", + "creator", + "previousContent", + "nextContent", + "buyerList", + "isAvailableUsePoint", + "translated" + ), + data.fieldNames().asSequence().toSet() + ) + assertEquals( + setOf("creatorId", "nickname", "profileImageUrl", "isFollowing", "isFollow", "isNotify"), + data.path("creator").fieldNames().asSequence().toSet() + ) + Mockito.verify(audioContentCloudFront).generateSignedURL("private/query release audio.mp3", 10_800_000L) + } + + @Test + @DisplayName("상세는 미래 예약일을 UTC Z releaseDate로 반환한다") + fun shouldReturnFutureReleaseDateAsUtcIso() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-future-release", + name = "v2-audio-query-future-release", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "future release audio", + releaseDate = LocalDateTime.of(2027, 7, 30, 10, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/future-release.mp3?Expires=1") + mockMvc.perform( get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") .with(adminAuthentication()) ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.releaseDateUtc").value("2026-07-25T00:00:00Z")) - .andExpect(jsonPath("$.data.releaseDate").doesNotExist()) - .andExpect(jsonPath("$.data.isOnlyRental").value(true)) - .andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY")) + .andExpect(jsonPath("$.data.releaseDate").value("2027-07-30T10:00:00Z")) + .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/future-release.mp3?Expires=1")) } @Test - @DisplayName("상세 응답 DTO는 legacy/public 중첩 DTO 타입을 직접 노출하지 않는다") - fun shouldUseV2NestedResponseTypes() { - val legacyPackages = setOf( - "kr.co.vividnext.sodalive.content", - "kr.co.vividnext.sodalive.content.comment", - "kr.co.vividnext.sodalive.content.translation" + @DisplayName("상세는 현재 또는 과거 예약일의 releaseDate를 null로 반환한다") + fun shouldReturnNullReleaseDateForPastOrCurrentReleaseDate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-past-release-character", + name = "v2-audio-query-past-release-character", + description = "description", + systemPrompt = "prompt" ) - - val nestedResponseFieldNames = setOf( - "creatorOtherContentList", - "sameThemeOtherContentList", - "commentList", - "creator", - "previousContent", - "nextContent", - "buyerList", - "translated" + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "past release audio", + releaseDate = LocalDateTime.of(2020, 1, 1, 0, 0) ) - val leakedTypes = AiCharacterAdminAudioContentResponse::class.java.declaredFields - .filter { it.name in nestedResponseFieldNames } - .map { it.type.packageName } - .filter { it in legacyPackages } + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/past-release.mp3?Expires=1") - assertFalse(leakedTypes.isNotEmpty(), "legacy nested DTO packages leaked: $leakedTypes") + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/past-release.mp3?Expires=1")) + .andReturn() + + assertTrue(objectMapper.readTree(response.response.contentAsString).path("data").path("releaseDate").isNull) + } + + @Test + @DisplayName("상세는 timezone query를 허용하지 않는다") + fun shouldRejectTimezoneForDetail() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-timezone-character", + name = "v2-audio-query-timezone-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "timezone required audio", + releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0) + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .param("timezone", "UTC") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + } + + @Test + @DisplayName("상세 응답은 레거시 GetAudioContentDetailResponse 타입을 재사용한다") + fun shouldReuseLegacyDetailResponseType() { + assertEquals(GetAudioContentDetailResponse::class.java, AiCharacterAdminAudioContentResponse::class.java) } private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent { @@ -104,6 +286,8 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor( title = title, detail = "detail", languageCode = "ko", + limited = 10, + remaining = 5, price = 100, purchaseOption = PurchaseOption.RENT_ONLY, isOnlyRental = false, @@ -122,6 +306,15 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor( } } + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + private fun adminAuthentication() = authentication( UsernamePasswordAuthenticationToken( MemberAdapter( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt index 52f91090..7cfcec31 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt @@ -1,10 +1,12 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content +import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -28,11 +30,12 @@ import javax.persistence.EntityManager @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor( private val mockMvc: MockMvc, + private val objectMapper: ObjectMapper, private val entityManager: EntityManager ) { @Test - @DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 v2 필드명으로 반환한다") - fun shouldReturnActiveThemesWithV2FieldNames() { + @DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 레거시 exact 필드로 반환한다") + fun shouldReturnActiveThemesWithLegacyFields() { val later = AudioContentTheme(theme = "later theme", image = "theme/later.png", orders = 2) val earlier = AudioContentTheme(theme = "earlier theme", image = "theme/earlier.png", orders = 1) val inactive = AudioContentTheme(theme = "inactive theme", image = "theme/inactive.png", isActive = false) @@ -41,19 +44,20 @@ class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor( entityManager.persist(inactive) entityManager.flush() - mockMvc.perform( + val response = mockMvc.perform( get("/api/v2/admin/ai-characters/audio-content-themes") .with(adminAuthentication()) ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.length()").value(2)) - .andExpect(jsonPath("$.data[0].themeId").value(earlier.id)) - .andExpect(jsonPath("$.data[0].themeName").value("earlier theme")) - .andExpect(jsonPath("$.data[0].imageUrl").value("https://test.cloudfront.net/theme/earlier.png")) - .andExpect(jsonPath("$.data[0].id").doesNotExist()) - .andExpect(jsonPath("$.data[0].theme").doesNotExist()) - .andExpect(jsonPath("$.data[0].image").doesNotExist()) + .andExpect(jsonPath("$.data[0].id").value(earlier.id)) + .andExpect(jsonPath("$.data[0].theme").value("earlier theme")) + .andExpect(jsonPath("$.data[0].image").value("https://test.cloudfront.net/theme/earlier.png")) + .andReturn() + + val item = objectMapper.readTree(response.response.contentAsString).path("data").path(0) + assertEquals(setOf("id", "theme", "image"), item.fieldNames().asSequence().toSet()) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt index 73be44cb..e1c269de 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt @@ -14,6 +14,7 @@ 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.assertSame @@ -21,15 +22,20 @@ 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.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 @@ -38,6 +44,7 @@ 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.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 @@ -77,9 +84,149 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher) } + @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 shouldRejectNonJsonRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language", + name = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "before update") + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + requestContentType, + "{\"title\":\"after update\"}".toByteArray() + ) + ) + .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 update", entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-undefined-part-$language", + name = "v2-audio-update-undefined-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "undefined-part-before") + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/undefined-part-before.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"should not change"}""".toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("undefined-part-before", entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-filename-less-part-$language", + name = "v2-audio-update-filename-less-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "filename-less-part-before") + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/filename-less-part-before.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"should not change"}""".toByteArray() + ) + ) + .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("filename-less-part-before", entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + @Test - @DisplayName("수정은 동일 seriesIds의 기존 연결 metadata를 보존한다") - fun shouldPreserveExistingSeriesContentMetadataForSameSeriesIds() { + @DisplayName("수정은 레거시 전체 request를 적용하고 기존 series metadata와 data null 계약을 보존한다") + fun shouldApplyLegacyRequestAndPreserveExistingSeriesMetadata() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-update-preserve-series-character", name = "v2-audio-update-preserve-series-character", @@ -103,24 +250,43 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, - """{"seriesIds":[${series.id}]}""".toByteArray() + MediaType.APPLICATION_JSON_VALUE, + """ + { + "title":"updated title", + "detail":"updated detail", + "tags":"#updated", + "price":250, + "isAdult":true, + "isActive":false, + "isPointAvailable":false, + "isCommentAvailable":false + } + """.trimIndent().toByteArray() ) ) .with(adminAuthentication()) ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.seriesIds[0]").value(series.id)) + .andExpect(jsonPath("$.data").value(nullValue())) val saved = findSeriesContents(content.id!!).single() assertEquals(beforeId, saved.id) assertEquals(7, saved.orders) assertEquals(beforeCreatedAt, saved.createdAt) + val updated = entityManager.find(AudioContent::class.java, content.id) + assertEquals("updated title", updated.title) + assertEquals("updated detail", updated.detail) + assertEquals(250, updated.price) + assertEquals(true, updated.isAdult) + assertEquals(false, updated.isActive) + assertEquals(false, updated.isPointAvailable) + assertEquals(false, updated.isCommentAvailable) } @Test - @DisplayName("수정은 seriesIds 교집합 metadata를 보존하고 차집합만 추가·제거한다") - fun shouldOnlyInsertAndDeleteSeriesContentDifference() { + @DisplayName("레거시 수정은 기존 series 연결을 추가·제거하지 않는다") + fun shouldLeaveSeriesConnectionsUnchanged() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "v2-audio-update-diff-series-character", name = "v2-audio-update-diff-series-character", @@ -147,21 +313,22 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, - """{"seriesIds":[${keptSeries.id},${addedSeries.id}]}""".toByteArray() + MediaType.APPLICATION_JSON_VALUE, + """{"title":"diff updated title"}""".toByteArray() ) ) .with(adminAuthentication()) ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.seriesIds.length()").value(2)) + .andExpect(jsonPath("$.data").value(nullValue())) val saved = findSeriesContents(content.id!!) - assertEquals(setOf(keptSeries.id, addedSeries.id), saved.map { it.series!!.id }.toSet()) + assertEquals(setOf(removedSeries.id, keptSeries.id), saved.map { it.series!!.id }.toSet()) val kept = saved.single { it.series!!.id == keptSeries.id } assertEquals(keptId, kept.id) assertEquals(5, kept.orders) assertEquals(keptCreatedAt, kept.createdAt) + assertEquals(false, saved.any { it.series!!.id == addedSeries.id }) } @Test @@ -181,7 +348,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") - .file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) .with(adminAuthentication()) ) .andExpect(status().isOk) @@ -193,7 +360,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) - .file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) .with(adminAuthentication()) ) .andExpect(status().isOk) @@ -221,7 +388,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") .file(MockMultipartFile("coverImage", "empty.png", "image/png", byteArrayOf())) - .file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) .with(adminAuthentication()) ) .andExpect(status().isOk) @@ -232,27 +399,28 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( Mockito.verifyNoInteractions(applicationEventPublisher) } - @Test - @DisplayName("수정은 빈 audioFile part도 교체 요청으로 거부한다") - fun shouldRejectEmptyAudioFileUpdate() { + @ParameterizedTest + @ValueSource(strings = ["audioFile", "contentFile"]) + @DisplayName("수정은 파일 교체 part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFileReplacementPartBeforeSideEffects(filePart: String) { val character = chatCharacterService.createChatCharacterWithDetails( - characterUUID = "v2-audio-update-empty-audio-character", - name = "v2-audio-update-empty-audio-character", + characterUUID = "v2-audio-update-file-replacement-$filePart", + name = "v2-audio-update-file-replacement-$filePart", description = "description", systemPrompt = "prompt" ) - val content = saveAudioContent(character.creatorMember!!, "empty-audio-update-content") + val content = saveAudioContent(character.creatorMember!!, "file-replacement-$filePart") val oldTitle = content.title entityManager.flush() mockMvc.perform( multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") - .file(MockMultipartFile("audioFile", "empty.mp3", "audio/mpeg", byteArrayOf())) + .file(MockMultipartFile(filePart, "empty.mp3", "audio/mpeg", byteArrayOf())) .file( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"title":"should not change"}""".toByteArray() ) ) @@ -290,7 +458,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( MockMultipartFile( "request", "request.json", - MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_JSON_VALUE, """{"title":"cover failure after title"}""".toByteArray() ) )