From ec4e4a57681ceee5f914d61646d97560a16291aa Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 28 Jul 2026 01:33:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiCharacterAdminAudioContentController.kt | 67 ++ .../AiCharacterAdminAudioContentDto.kt | 151 +++ .../AiCharacterAdminAudioContentFacade.kt | 246 ++++ .../AiCharacterAdminAudioContentMapper.kt | 121 ++ .../AiCharacterAdminAudioContentRepository.kt | 143 +++ .../error/AiCharacterAdminExceptionHandler.kt | 2 + .../sodalive/support/H2MysqlDateFunctions.kt | 2 + ...haracterAdminAudioContentControllerTest.kt | 1030 +++++++++++++++++ .../AiCharacterAdminAudioContentCreateTest.kt | 436 +++++++ ...CharacterAdminAudioContentOwnershipTest.kt | 344 ++++++ .../AiCharacterAdminAudioContentQueryTest.kt | 139 +++ ...terAdminAudioContentThemeControllerTest.kt | 83 ++ .../AiCharacterAdminAudioContentUpdateTest.kt | 383 ++++++ ...ioContentCloudFrontCharacterizationTest.kt | 70 ++ ...orAdminAudioContentCharacterizationTest.kt | 251 ++++ 15 files changed, 3468 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt 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 new file mode 100644 index 00000000..df929b68 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt @@ -0,0 +1,67 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.http.MediaType +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.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 + +@RestController +@RequestMapping("/api/v2/admin/ai-characters") +class AiCharacterAdminAudioContentController( + private val facade: AiCharacterAdminAudioContentFacade +) { + @GetMapping("/audio-content-themes") + fun themes(): ApiResponse> { + return ApiResponse.ok(facade.themes()) + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents") + fun list( + @PathVariable characterId: Long, + @RequestParam(required = false) search: String?, + @RequestParam(required = false) status: String?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, search, status, page, size)) + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}") + fun detail( + @PathVariable characterId: Long, + @PathVariable contentId: Long + ): ApiResponse { + return ApiResponse.ok(facade.detail(characterId, contentId)) + } + + @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)) + } + + @PutMapping( + "/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}", + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE] + ) + fun update( + @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)) + } +} 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 new file mode 100644 index 00000000..24b70861 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt @@ -0,0 +1,151 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.content.PurchaseOption +import kr.co.vividnext.sodalive.content.order.OrderType + +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 +) + +data class AiCharacterAdminAudioContentCreateRequest( + val title: String, + val description: 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 isGeneratePreview: Boolean = false, + val isOnlyRental: Boolean = false, + val isPointAvailable: Boolean = false, + val isCommentAvailable: Boolean = false, + val isFullDetailVisible: Boolean = true, + val previewStartTime: String? = null, + val previewEndTime: String? = null, + val languageCode: String? = null, + val seriesIds: List = emptyList() +) + +data class AiCharacterAdminAudioContentUpdateRequest( + val title: String? = null, + val description: 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 new file mode 100644 index 00000000..15d5b82f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt @@ -0,0 +1,246 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.fasterxml.jackson.core.JsonProcessingException +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.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +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 +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.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +@Service +class AiCharacterAdminAudioContentFacade( + private val objectMapper: ObjectMapper, + private val targetResolver: AiCharacterAdminTargetResolver, + private val repository: AiCharacterAdminAudioContentRepository, + private val themeQueryRepository: AudioContentThemeQueryRepository, + private val mapper: AiCharacterAdminAudioContentMapper, + private val audioContentService: AudioContentService, + private val creatorAdminContentService: CreatorAdminContentService +) { + @Transactional(readOnly = true) + fun themes(): List { + return themeQueryRepository.getActiveThemes().map { + AiCharacterAdminAudioContentThemeResponse( + themeId = it.id, + themeName = it.theme, + imageUrl = it.image + ) + } + } + + @Transactional(readOnly = true) + fun list( + characterId: Long, + search: String?, + status: 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() + ) + } + + @Transactional(readOnly = true) + fun detail(characterId: Long, contentId: Long): AiCharacterAdminAudioContentResponse { + 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()) + } + + @Transactional + fun create( + characterId: Long, + coverImage: MultipartFile, + audioFile: MultipartFile, + requestString: String + ): AiCharacterAdminAudioContentResponse { + val target = targetResolver.resolve(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + if (coverImage.isEmpty || audioFile.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, + 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 + ) + ), + member = target.creatorMember + ) + val content = repository.findByIdAndCreatorMemberId(created.contentId, creatorMemberId) ?: throw invalidRequest() + replaceSeriesIds(content, request.seriesIds, creatorMemberId) + + return mapper.toResponse(content, creatorMemberId) + } + + @Transactional + fun update( + 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( + id = contentId, + title = request.title, + detail = request.description, + tags = request.tags, + price = request.price, + isAdult = request.isAdult, + isActive = request.isActive, + isPointAvailable = request.isPointAvailable, + isCommentAvailable = request.isCommentAvailable + ) + ), + 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) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun hasField(requestString: String, fieldName: String): Boolean { + return try { + objectMapper.readTree(requestString).has(fieldName) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun String.toLegacyReleaseDate(): String { + return toUtcLocalDateTime().format(LEGACY_RELEASE_DATE_FORMATTER) + } + + private fun String.toUtcLocalDateTime(): LocalDateTime { + return try { + Instant.parse(this).atOffset(ZoneOffset.UTC).toLocalDateTime() + } catch (_: RuntimeException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + companion object { + private const val MINIMUM_PAGE_SIZE = 20 + private const val MAXIMUM_PAGE_SIZE = 50 + private const val UTC_TIMEZONE = "UTC" + private val LEGACY_RELEASE_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + } + + 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 new file mode 100644 index 00000000..a2dea6bc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt @@ -0,0 +1,121 @@ +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.PurchaseOption +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() + 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, + 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, + totalContentCount = content.limited, + remainingContentCount = content.remaining, + orderSequence = null, + isActivePreview = content.isGeneratePreview, + isMosaic = false, + isOnlyRental = content.purchaseOption == PurchaseOption.RENT_ONLY || content.isOnlyRental, + existOrdered = false, + purchaseOption = if (content.isOnlyRental) PurchaseOption.RENT_ONLY else content.purchaseOption, + orderType = null, + remainingTime = null, + creatorOtherContentList = emptyList(), + sameThemeOtherContentList = emptyList(), + isLike = false, + likeCount = 0, + commentList = emptyList(), + commentCount = 0, + isPin = false, + isAvailablePin = false, + creator = AiCharacterAdminAudioContentCreatorResponse( + creatorId = owner.id ?: throw invalidRequest(), + nickname = owner.nickname, + profileImageUrl = if (owner.profileImage != null) { + "$imageHost/${owner.profileImage}" + } else { + "$imageHost/profile/default-profile.png" + }, + isFollowing = false, + isFollow = false, + isNotify = false + ), + previousContent = null, + 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() + ) + } + + private fun signedUrl(content: AudioContent): String? { + val resourcePath = content.content ?: return null + val duration = content.duration ?: return null + val expirationTime = 1000 * 60 * 60 * (duration.split(":")[0].toLong() + 2) + 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 invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt new file mode 100644 index 00000000..37049f03 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt @@ -0,0 +1,143 @@ +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.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 +) { + 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) + .innerJoin(audioContent.member, member) + .where(audioContent.id.eq(contentId).and(member.id.eq(creatorMemberId))) + .fetchOne() + } + + fun findSeriesIds(contentId: Long, creatorMemberId: Long): List { + return queryFactory + .select(series.id) + .from(seriesContent) + .innerJoin(seriesContent.series, series) + .innerJoin(seriesContent.content, audioContent) + .where( + audioContent.id.eq(contentId) + .and(audioContent.member.id.eq(creatorMemberId)) + .and(series.member.id.eq(creatorMemberId)) + .and(series.isActive.isTrue) + ) + .fetch() + } + + 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 { + return queryFactory + .selectFrom(seriesContent) + .innerJoin(seriesContent.series, series) + .where( + seriesContent.content.id.eq(contentId) + .and(series.member.id.eq(creatorMemberId)) + ) + .fetch() + } + + 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 { + return queryFactory + .selectFrom(series) + .where( + series.id.`in`(seriesIds) + .and(series.member.id.eq(creatorMemberId)) + .and(series.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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt index 26c91eb7..d880c775 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error +import kr.co.vividnext.sodalive.common.SodaException import org.slf4j.LoggerFactory import org.springframework.core.Ordered import org.springframework.http.HttpHeaders @@ -54,6 +55,7 @@ class AiCharacterAdminExceptionHandler( private fun resolveError(exception: Exception): ResolvedError { return when (exception) { is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey) + is SodaException -> ResolvedError(HttpStatus.BAD_REQUEST, exception.messageKey ?: "common.error.invalid_request") is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied") is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED) is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE) 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 eb0a446d..360645c1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -29,6 +29,8 @@ class H2MysqlDateFunctions { .replace("%Y", "yyyy") .replace("%m", "MM") .replace("%d", "dd") + .replace("%H", "HH") + .replace("%i", "mm") return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) } 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 new file mode 100644 index 00000000..0ef7132c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt @@ -0,0 +1,1030 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.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.AudioContentService +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +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.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 +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val audioContentService: AudioContentService, + private val creatorAdminContentService: CreatorAdminContentService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublishers() { + originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher) + originalCreatorAdminContentServicePublisher = replacePublisher( + creatorAdminContentService, + applicationEventPublisher + ) + } + + @AfterEach + fun restoreActualServicePublishers() { + replacePublisher(audioContentService, originalAudioContentServicePublisher) + replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher) + } + + @Test + @DisplayName("생성은 themeId, 예약일, seriesIds를 포함한 multipart 요청으로 target 소유 콘텐츠 상세를 반환한다") + fun shouldCreateOwnedContentWithThemeIdReleaseDateAndSeriesIds() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-character", + name = "v2-audio-create-character", + description = "description", + systemPrompt = "prompt" + ) + 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")) + + 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( + "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"} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .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()) + } + + @Test + @DisplayName("수정은 seriesIds를 target 소유 시리즈로 교체하고 상세를 반환한다") + fun shouldReplaceOwnedContentSeriesIds() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-series-update-character", + name = "v2-audio-series-update-character", + description = "description", + 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", + contentPath = "private/series-update.mp3" + ) + saveSeriesContent(oldSeries, content) + entityManager.flush() + + 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, + """{"seriesIds":[${newSeries.id}]}""".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)) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다") + fun shouldRejectCreateWithOtherCharacterSeriesIdsBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-series-target", + name = "v2-audio-create-series-target", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-series-other", + name = "v2-audio-create-series-other", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-invalid-series-theme", image = "theme.png") + entityManager.persist(theme) + val otherSeries = saveSeries("other-series", otherCharacter.creatorMember!!) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + 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( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"invalid series audio","description":"created detail","price":100,"isAdult":false,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @Test + @DisplayName("생성은 처리 완료 전 유지할 수 없는 isActive false를 거부한다") + fun shouldRejectCreateWithInactiveRequest() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-inactive-character", + name = "v2-audio-create-inactive-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-inactive-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + + 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( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"inactive audio","description":"created detail","price":100,"isAdult":false,"isActive":false,"themeId":${theme.id},"seriesIds":[]} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("생성의 기존 콘텐츠 검증 오류는 invalid request envelope으로 반환한다") + fun shouldMapCreateValidationErrorToBadRequestEnvelope() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-validation-character", + name = "v2-audio-create-validation-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-validation-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + + 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( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"invalid price audio","description":"created detail","price":1,"isAdult":false,"themeId":${theme.id},"seriesIds":[]} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("수정은 soft delete와 예약일 재설정을 같은 요청에서 거부한다") + fun shouldRejectSoftDeleteWithReleaseDateUtc() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-soft-delete-release-character", + name = "v2-audio-soft-delete-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "soft delete release title", + contentPath = "private/soft-delete-release.mp3" + ) + entityManager.flush() + entityManager.clear() + + 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, + """{"isActive":false,"releaseDateUtc":"2026-07-25T00:00:00Z"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(true, entityManager.find(AudioContent::class.java, content.id).isActive) + } + + @Test + @DisplayName("AI 캐릭터 콘텐츠 목록은 target 소유 공개 콘텐츠만 signed URL과 보정된 페이지로 반환한다") + fun shouldListTargetContentsWithSignedUrlAndNormalizedPagination() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-character", + name = "v2-audio-list-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "target night walk", + contentPath = "private/target-night-walk.mp3" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-other-character", + name = "v2-audio-other-character", + description = "description", + systemPrompt = "prompt" + ) + saveAudioContent( + owner = otherCharacter.creatorMember!!, + title = "other night walk", + contentPath = "private/other-night-walk.mp3" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "scheduled night walk", + contentPath = "private/scheduled-night-walk.mp3", + releaseDate = Instant.now().plusSeconds(86_400).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("search", "night") + .param("status", "OPEN") + .param("page", "-1") + .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].title").value("target night walk")) + .andExpect( + jsonPath("$.data.items[0].audioSignedUrl").value( + "https://signed.example.com/private/target-night-walk.mp3?Expires=1" + ) + ) + .andExpect(jsonPath("$.data.items[0].content").doesNotExist()) + + Mockito.verify(audioContentCloudFront).generateSignedURL( + "private/target-night-walk.mp3", + 10_800_000L + ) + } + + @Test + @DisplayName("status 미지정 목록은 target 소유 공개와 예약 콘텐츠를 함께 반환한다") + fun shouldListOpenAndScheduledContentsWhenStatusIsMissing() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-default-status-character", + name = "v2-audio-list-default-status-character", + description = "description", + systemPrompt = "prompt" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "open default status", + contentPath = "private/open-default-status.mp3" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "scheduled default status", + contentPath = "private/scheduled-default-status.mp3", + releaseDate = Instant.now().plusSeconds(86_400).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + } + + @Test + @DisplayName("status SCHEDULED 목록은 target 소유 예약 콘텐츠만 반환한다") + fun shouldListScheduledContentsWhenStatusIsScheduled() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-scheduled-status-character", + name = "v2-audio-list-scheduled-status-character", + description = "description", + systemPrompt = "prompt" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "open explicit status", + contentPath = "private/open-explicit-status.mp3" + ) + val scheduled = saveAudioContent( + owner = character.creatorMember!!, + title = "scheduled explicit status", + contentPath = "private/scheduled-explicit-status.mp3", + releaseDate = Instant.now().plusSeconds(86_400).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("status", "SCHEDULED") + .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")) + } + + @Test + @DisplayName("status SCHEDULED 목록은 UTC 기준 미래 예약 콘텐츠를 서버 시간대와 무관하게 반환한다") + fun shouldListUtcFutureScheduledContentsWhenServerTimezoneIsAheadOfUtc() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-utc-scheduled-character", + name = "v2-audio-list-utc-scheduled-character", + description = "description", + systemPrompt = "prompt" + ) + val scheduled = saveAudioContent( + owner = character.creatorMember!!, + title = "utc future scheduled status", + contentPath = "private/utc-future-scheduled-status.mp3", + releaseDate = Instant.now().plusSeconds(3600).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("status", "SCHEDULED") + .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")) + } + + @Test + @DisplayName("AI 캐릭터 콘텐츠 상세는 signed URL만 반환하고 private object path 필드는 노출하지 않는다") + fun shouldReturnOwnedContentDetailWithSignedUrlWithoutPrivatePath() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-detail-character", + name = "v2-audio-detail-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "detail night walk", + contentPath = "private/detail-night-walk.mp3" + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/private/detail-night-walk.mp3?Expires=1") + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .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.totalContentCount").doesNotExist()) + .andExpect(jsonPath("$.data.remainingContentCount").doesNotExist()) + .andExpect(jsonPath("$.data.orderSequence").doesNotExist()) + .andExpect(jsonPath("$.data.isActivePreview").value(true)) + .andExpect(jsonPath("$.data.isMosaic").value(false)) + .andExpect(jsonPath("$.data.isOnlyRental").value(false)) + .andExpect(jsonPath("$.data.existOrdered").value(false)) + .andExpect(jsonPath("$.data.purchaseOption").value("BOTH")) + .andExpect(jsonPath("$.data.orderType").doesNotExist()) + .andExpect(jsonPath("$.data.remainingTime").doesNotExist()) + .andExpect(jsonPath("$.data.creatorOtherContentList").isEmpty) + .andExpect(jsonPath("$.data.sameThemeOtherContentList").isEmpty) + .andExpect(jsonPath("$.data.isLike").value(false)) + .andExpect(jsonPath("$.data.likeCount").value(0)) + .andExpect(jsonPath("$.data.commentList").isEmpty) + .andExpect(jsonPath("$.data.commentCount").value(0)) + .andExpect(jsonPath("$.data.isPin").value(false)) + .andExpect(jsonPath("$.data.isAvailablePin").value(false)) + .andExpect(jsonPath("$.data.creator.creatorId").value(character.creatorMember!!.id)) + .andExpect(jsonPath("$.data.previousContent").doesNotExist()) + .andExpect(jsonPath("$.data.nextContent").doesNotExist()) + .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.content").doesNotExist()) + + Mockito.verify(audioContentCloudFront).generateSignedURL( + "private/detail-night-walk.mp3", + 10_800_000L + ) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("다른 AI 캐릭터 소유 콘텐츠 상세 접근은 invalid request envelope으로 거부한다") + fun shouldRejectCrossCharacterContentDetail(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-target-character", + name = "v2-audio-target-character", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-cross-character", + name = "v2-audio-cross-character", + description = "description", + systemPrompt = "prompt" + ) + val otherContent = saveAudioContent( + owner = otherCharacter.creatorMember!!, + title = "other content", + contentPath = "private/other-content.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${otherContent.id}") + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("other content", entityManager.find(AudioContent::class.java, otherContent.id).title) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("다른 AI 캐릭터 소유 콘텐츠 수정은 DB 변경 없이 invalid request envelope으로 거부한다") + fun shouldRejectCrossCharacterContentUpdateWithoutMutation(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-target-character", + name = "v2-audio-update-target-character", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-cross-character", + name = "v2-audio-update-cross-character", + description = "description", + systemPrompt = "prompt" + ) + val otherContent = saveAudioContent( + owner = otherCharacter.creatorMember!!, + title = "other before title", + contentPath = "private/other-before-title.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${otherContent.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + """{"title":"other after title"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("other before title", entityManager.find(AudioContent::class.java, otherContent.id).title) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 다른 캐릭터 소유 seriesIds를 표지 업로드 전 거부한다") + fun shouldRejectUpdateWithOtherCharacterSeriesIdsBeforeCoverUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-series-target", + name = "v2-audio-update-series-target", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-series-other", + name = "v2-audio-update-series-other", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "before invalid series", + contentPath = "private/before-invalid-series.mp3" + ) + val ownedSeries = saveSeries("update-owned-series", character.creatorMember!!) + saveSeriesContent(ownedSeries, content) + val otherSeries = saveSeries("update-other-series", otherCharacter.creatorMember!!) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + 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, + """{"seriesIds":[${otherSeries.id}]}""".toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("before invalid series", entityManager.find(AudioContent::class.java, content.id).title) + assertEquals(ownedSeries.id, linkedSeriesId(content.id!!)) + } + } + + @Test + @DisplayName("수정은 audioFile 교체를 거부한다") + fun shouldRejectAudioFileUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-audio-file-character", + name = "v2-audio-update-audio-file-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "audio file update title", + contentPath = "private/audio-file-update.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + 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( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + "{}".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("audio file update title", entityManager.find(AudioContent::class.java, content.id).title) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 잘못된 releaseDateUtc를 표지 업로드 전 거부한다") + fun shouldRejectInvalidReleaseDateUtcBeforeCoverUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-invalid-release-character", + name = "v2-audio-update-invalid-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "invalid release title", + contentPath = "private/invalid-release.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + val beforeReleaseDate = content.releaseDate + + 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, + """{"releaseDateUtc":"not-a-date"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + val persistedContent = entityManager.find(AudioContent::class.java, content.id) + assertEquals("invalid release title", persistedContent.title) + assertEquals(beforeReleaseDate, persistedContent.releaseDate) + } + } + + @Test + @DisplayName("수정은 활성 콘텐츠를 미래 예약으로 변경할 때 비활성화해 조기 공개를 막는다") + fun shouldDeactivateContentWhenUpdatingFutureReleaseDateUtc() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-future-release-character", + name = "v2-audio-update-future-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "future release title", + contentPath = "private/future-release.mp3" + ) + entityManager.flush() + val releaseDateUtc = Instant.now().plusSeconds(3600).atOffset(ZoneOffset.UTC).format( + java.time.format.DateTimeFormatter.ISO_INSTANT + ) + + 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, + """{"releaseDateUtc":"$releaseDateUtc"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.isActive").value(false)) + .andExpect(jsonPath("$.data.status").value("SCHEDULED")) + + assertEquals(false, entityManager.find(AudioContent::class.java, content.id).isActive) + } + + @Test + @DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 상세를 반환한다") + fun shouldSoftDeleteOwnedContentAndReturnDetail() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-character", + name = "v2-audio-update-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "before title", + contentPath = "private/before-title.mp3" + ) + entityManager.flush() + + 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, + """{"title":"after title","isActive":false}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .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()) + } + + @Test + @DisplayName("콘텐츠 목록은 ADMIN 인증을 요구하고 기본 페이지 값을 사용한다") + fun shouldRequireAdminAuthenticationAndUseDefaultPagination() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-auth-character", + name = "v2-audio-auth-character", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.size").value(20)) + } + + private fun saveAudioContent( + owner: Member, + title: String, + contentPath: String, + releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime() + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = 100, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + this.releaseDate = releaseDate + duration = "01:10:00" + content = contentPath + coverImage = "cover/$title.png" + entityManager.persist(this) + } + } + + private fun saveSeries(title: String, owner: Member): Series { + val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true) + entityManager.persist(genre) + val series = Series(title = title, introduction = "introduction", languageCode = "ko") + series.member = owner + series.genre = genre + series.coverImage = "$title.png" + entityManager.persist(series) + return series + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + + private fun ResultActions.andExpectApiError(message: String) { + andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun assertNoSideEffects( + beforeAudioContents: Long, + beforeSeriesContents: Long, + stateAssertion: () -> Unit = {} + ) { + assertActualPublisher(applicationEventPublisher, audioContentService) + assertActualPublisher(applicationEventPublisher, creatorAdminContentService) + entityManager.flush() + entityManager.clear() + assertEquals(beforeAudioContents, countAudioContents()) + assertEquals(beforeSeriesContents, countSeriesContents()) + stateAssertion() + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun replacePublisher(service: Any, publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(service) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualPublisher(publisher: ApplicationEventPublisher, service: Any) { + val target = AopTestUtils.getTargetObject(service) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun countAudioContents(): Long { + return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun countSeriesContents(): Long { + return entityManager.createQuery("select count(sc) from SeriesContent sc", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun linkedSeriesId(contentId: Long): Long { + return entityManager.createQuery( + "select sc.series.id from SeriesContent sc where sc.content.id = :contentId", + java.lang.Long::class.java + ) + .setParameter("contentId", contentId) + .singleResult + .toLong() + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt new file mode 100644 index 00000000..d82eb9db --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt @@ -0,0 +1,436 @@ +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 kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +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.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.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.RequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.multipart.support.MissingServletRequestPartException +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentCreateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val audioContentService: AudioContentService, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublisher() { + originalAudioContentServicePublisher = replaceAudioContentServicePublisher(applicationEventPublisher) + } + + @AfterEach + fun restoreActualServicePublisher() { + replaceAudioContentServicePublisher(originalAudioContentServicePublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 coverImage part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingCoverImageBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-cover-character", + name = "v2-audio-create-missing-cover-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertMissingPartRequest( + requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()), + message = message + ) + } + + @ParameterizedTest + @CsvSource( + "coverImage,ko,잘못된 요청입니다.", + "coverImage,en,Invalid request.", + "coverImage,ja,無効なリクエストです。", + "audioFile,ko,잘못된 요청입니다.", + "audioFile,en,Invalid request.", + "audioFile,ja,無効なリクエストです。" + ) + @DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다") + fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-empty-file-$filePart-$language", + name = "v2-audio-create-empty-file-$filePart-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1) + val audioBytes = if (filePart == "audioFile") 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(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("생성은 audioFile part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingAudioFileBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-audio-character", + name = "v2-audio-create-missing-audio-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertMissingPartRequest( + requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()), + message = message + ) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 request part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingRequestBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-request-character", + name = "v2-audio-create-missing-request-character", + description = "description", + systemPrompt = "prompt" + ) + + 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))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()), + message = message + ) + } + + @Test + @DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingThemeBeforeUpload() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-theme-character", + name = "v2-audio-create-missing-theme-character", + description = "description", + systemPrompt = "prompt" + ) + + 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(requestPart(999999L)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid theme. Please select again.")) + + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다") + fun shouldRollBackDatabaseAndEventWhenCoverUploadFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-cover-failure-character", + name = "v2-audio-create-cover-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = transactionTemplate.execute { saveTheme() }!! + Mockito.doThrow(IllegalStateException("cover upload failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + + 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(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An unknown error occurred. try again.")) + + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertNull(findAudioContentByTitle("audio")) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 audio 업로드 실패 시 DB와 이벤트를 남기지 않고 cover 업로드 호출만 남긴다") + fun shouldRollBackDatabaseAndEventWhenAudioUploadFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-audio-failure-character", + name = "v2-audio-create-audio-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = transactionTemplate.execute { saveTheme() }!! + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + Mockito.`when`(amazonS3Client.putObject(Mockito.any(PutObjectRequest::class.java))) + .thenReturn(PutObjectResult()) + .thenThrow(IllegalStateException("audio upload failure")) + + 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(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An unknown error occurred. try again.")) + + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertNull(findAudioContentByTitle("audio")) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 이벤트 발행 실패 시 DB를 롤백하고 두 S3 업로드 호출만 남긴다") + fun shouldRollBackDatabaseWhenCreateEventPublishFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-event-failure-character", + name = "v2-audio-create-event-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = transactionTemplate.execute { saveTheme() }!! + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + val originalPublisher = replaceAudioContentServicePublisher(applicationEventPublisher) + Mockito.doThrow(IllegalStateException("event failure")) + .`when`(applicationEventPublisher) + .publishEvent(Mockito.any(Any::class.java)) + + try { + 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(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An unknown error occurred. try again.")) + } finally { + replaceAudioContentServicePublisher(originalPublisher) + } + + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) + assertNull(findAudioContentByTitle("audio")) + } + + @Test + @DisplayName("생성은 tags 누락과 isActive true를 legacy pipeline 기본 계약으로 허용한다") + fun shouldAllowMissingTagsAndActiveTrueRequest() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-minimal-character", + name = "v2-audio-create-minimal-character", + 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("audioFile", "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} + """.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)) + } + + private fun saveTheme(): AudioContentTheme { + val theme = AudioContentTheme(theme = "create-test-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + return theme + } + + private fun requestPart(themeId: Long): MockMultipartFile { + return MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"audio","description":"detail","price":100,"themeId":$themeId,"isActive":true} + """.trimIndent().toByteArray() + ) + } + + private fun assertMissingPartRequest( + requestBuilder: RequestBuilder, + message: String + ) { + val result = mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + 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 + .toLong() + } + + private fun findAudioContentByTitle(title: String): AudioContent? { + return entityManager.createQuery( + "select c from AudioContent c where c.title = :title", + AudioContent::class.java + ).setParameter("title", title).resultList.firstOrNull() + } + + private fun replaceAudioContentServicePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(audioContentService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualAudioContentServicePublisher(publisher: ApplicationEventPublisher) { + val target = AopTestUtils.getTargetObject(audioContentService) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt new file mode 100644 index 00000000..5c3a055f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt @@ -0,0 +1,344 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +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 +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.Arguments +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request +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 java.util.stream.Stream +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor( + private val mockMvc: MockMvc, + private val audioContentService: AudioContentService, + private val creatorAdminContentService: CreatorAdminContentService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublishers() { + originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher) + originalCreatorAdminContentServicePublisher = replacePublisher( + creatorAdminContentService, + applicationEventPublisher + ) + } + + @AfterEach + fun restoreActualServicePublishers() { + replacePublisher(audioContentService, originalAudioContentServicePublisher) + replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher) + } + + @ParameterizedTest + @MethodSource("malformedIdentifierRequests") + @DisplayName("숫자가 아닌 콘텐츠 식별자는 미매핑 404 i18n envelope으로 처리한다") + fun shouldReturnNotFoundEnvelopeForMalformedContentIdentifiers(method: HttpMethod, path: String) { + val result = mockMvc.perform( + request(method, path) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectApiError(404, "Invalid request.") + } + + @ParameterizedTest + @MethodSource("phase3EndpointRequests") + @DisplayName("실제 Phase 3 endpoint는 JWT ADMIN이 아니면 binding 전에 거부한다") + fun shouldRejectNonAdminJwtRoleBeforePhase3EndpointBinding(method: HttpMethod, path: String) { + val result = mockMvc.perform( + request(method, path) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(authentication(MemberRole.USER, MemberRole.ADMIN)) + ) + + result.andExpectApiError(403, "You do not have permission.") + } + + @ParameterizedTest + @MethodSource("phase3EndpointRequests") + @DisplayName("실제 Phase 3 endpoint는 stale ADMIN claim을 binding 전에 거부한다") + fun shouldRejectStaleAdminClaimBeforePhase3EndpointBinding(method: HttpMethod, path: String) { + val result = mockMvc.perform( + request(method, path) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(authentication(MemberRole.ADMIN, MemberRole.USER)) + ) + + result.andExpectApiError(403, "You do not have permission.") + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 생성은 S3 업로드 전에 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeCreateUpload(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + 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( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + "{}".toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 목록은 DB/S3/event 변경 없이 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeListSideEffects(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + val result = mockMvc.perform( + request(HttpMethod.GET, "/api/v2/admin/ai-characters/999999/audio-contents") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 상세는 DB/S3/event 변경 없이 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeDetailSideEffects(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + val result = mockMvc.perform( + request(HttpMethod.GET, "/api/v2/admin/ai-characters/999999/audio-contents/1") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 수정은 DB/S3/event 변경 없이 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeUpdateSideEffects(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + 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())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + private fun assertNoSideEffects(beforeAudioContents: Long, beforeSeriesContents: Long) { + assertActualPublisher(applicationEventPublisher, audioContentService) + assertActualPublisher(applicationEventPublisher, creatorAdminContentService) + assertEquals(beforeAudioContents, countAudioContents()) + assertEquals(beforeSeriesContents, countSeriesContents()) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun replacePublisher(service: Any, publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(service) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualPublisher(publisher: ApplicationEventPublisher, service: Any) { + val target = AopTestUtils.getTargetObject(service) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + @ParameterizedTest + @MethodSource("phase3EndpointRequests") + @DisplayName("실제 Phase 3 endpoint preflight는 캐릭터 관리자 Origin만 허용한다") + fun shouldApplyCorsPreflightToEveryPhase3Endpoint(method: HttpMethod, path: String) { + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method.name) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method.name) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("콘텐츠 테마 preflight는 캐릭터 관리자 Origin만 허용한다") + fun shouldAllowOnlyCharacterAdminOriginForThemePreflight() { + mockMvc.perform( + options("/api/v2/admin/ai-characters/audio-content-themes") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options("/api/v2/admin/ai-characters/audio-content-themes") + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + private fun authentication(jwtRole: MemberRole, currentRole: MemberRole) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = currentRole + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}")) + ) + ) + + private fun adminAuthentication() = authentication(MemberRole.ADMIN, MemberRole.ADMIN) + + private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) { + andExpect(status().`is`(httpStatus)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun countAudioContents(): Long { + return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun countSeriesContents(): Long { + return entityManager.createQuery("select count(sc) from SeriesContent sc", java.lang.Long::class.java) + .singleResult + .toLong() + } + + companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + private const val CREATOR_ORIGIN = "https://creator.sodalive.net" + + @JvmStatic + fun malformedIdentifierRequests(): Stream = Stream.of( + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/not-a-number/audio-contents"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/not-a-number/audio-contents/1"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents/not-a-number"), + Arguments.of(HttpMethod.POST, "/api/v2/admin/ai-characters/not-a-number/audio-contents"), + Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/not-a-number/audio-contents/1"), + Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/audio-contents/not-a-number") + ) + + @JvmStatic + fun phase3EndpointRequests(): Stream = Stream.of( + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/audio-content-themes"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents/1"), + Arguments.of(HttpMethod.POST, "/api/v2/admin/ai-characters/1/audio-contents"), + Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/audio-contents/1") + ) + } +} 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 new file mode 100644 index 00000000..16d54b27 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +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.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.DisplayName +import org.junit.jupiter.api.Test +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.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import 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 AiCharacterAdminAudioContentQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @Test + @DisplayName("상세는 과거 공개일을 releaseDateUtc에만 반환하고 legacy releaseDate는 비운다") + fun shouldExposePastReleaseDateOnlyAsUtcField() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-release-character", + name = "v2-audio-query-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "query release audio", + releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/query-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")) + } + + @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" + ) + + val nestedResponseFieldNames = setOf( + "creatorOtherContentList", + "sameThemeOtherContentList", + "commentList", + "creator", + "previousContent", + "nextContent", + "buyerList", + "translated" + ) + val leakedTypes = AiCharacterAdminAudioContentResponse::class.java.declaredFields + .filter { it.name in nestedResponseFieldNames } + .map { it.type.packageName } + .filter { it in legacyPackages } + + assertFalse(leakedTypes.isNotEmpty(), "legacy nested DTO packages leaked: $leakedTypes") + } + + private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = 100, + purchaseOption = PurchaseOption.RENT_ONLY, + isOnlyRental = false, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + this.releaseDate = releaseDate + duration = "01:10:00" + content = "private/$title.mp3" + coverImage = "cover/$title.png" + entityManager.persist(this) + } + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} 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 new file mode 100644 index 00000000..52f91090 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt @@ -0,0 +1,83 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +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.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager +) { + @Test + @DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 v2 필드명으로 반환한다") + fun shouldReturnActiveThemesWithV2FieldNames() { + 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) + entityManager.persist(later) + entityManager.persist(earlier) + entityManager.persist(inactive) + entityManager.flush() + + 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()) + } + + @Test + @DisplayName("콘텐츠 테마 목록은 ADMIN 인증을 요구한다") + fun shouldRequireAdminAuthentication() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/audio-content-themes") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + 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/AiCharacterAdminAudioContentUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt new file mode 100644 index 00000000..73be44cb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt @@ -0,0 +1,383 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.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.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +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.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.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +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.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.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.Instant +import java.time.ZoneOffset +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val creatorAdminContentService: CreatorAdminContentService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublisher() { + originalCreatorAdminContentServicePublisher = replaceCreatorAdminContentServicePublisher(applicationEventPublisher) + } + + @AfterEach + fun restoreActualServicePublisher() { + replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher) + } + + @Test + @DisplayName("수정은 동일 seriesIds의 기존 연결 metadata를 보존한다") + fun shouldPreserveExistingSeriesContentMetadataForSameSeriesIds() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-preserve-series-character", + name = "v2-audio-update-preserve-series-character", + description = "description", + systemPrompt = "prompt" + ) + val series = saveSeries("preserve-series", character.creatorMember!!) + val content = saveAudioContent(character.creatorMember!!, "preserve-series-content") + val seriesContent = saveSeriesContent(series, content).apply { orders = 7 } + entityManager.flush() + + val beforeId = seriesContent.id + val beforeCreatedAt = seriesContent.createdAt + entityManager.clear() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/preserve-series-content.mp3") + + 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, + """{"seriesIds":[${series.id}]}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.seriesIds[0]").value(series.id)) + + val saved = findSeriesContents(content.id!!).single() + assertEquals(beforeId, saved.id) + assertEquals(7, saved.orders) + assertEquals(beforeCreatedAt, saved.createdAt) + } + + @Test + @DisplayName("수정은 seriesIds 교집합 metadata를 보존하고 차집합만 추가·제거한다") + fun shouldOnlyInsertAndDeleteSeriesContentDifference() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-diff-series-character", + name = "v2-audio-update-diff-series-character", + description = "description", + systemPrompt = "prompt" + ) + val removedSeries = saveSeries("removed-series", character.creatorMember!!) + val keptSeries = saveSeries("kept-series", character.creatorMember!!) + val addedSeries = saveSeries("added-series", character.creatorMember!!) + val content = saveAudioContent(character.creatorMember!!, "diff-series-content") + saveSeriesContent(removedSeries, content) + val keptSeriesContent = saveSeriesContent(keptSeries, content).apply { orders = 5 } + entityManager.flush() + + val keptId = keptSeriesContent.id + val keptCreatedAt = keptSeriesContent.createdAt + entityManager.clear() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/diff-series-content.mp3") + + 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, + """{"seriesIds":[${keptSeries.id},${addedSeries.id}]}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.seriesIds.length()").value(2)) + + val saved = findSeriesContents(content.id!!) + assertEquals(setOf(keptSeries.id, addedSeries.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) + } + + @Test + @DisplayName("수정은 cover가 없으면 유지하고 성공 교체 시 새 cover path를 저장한다") + fun shouldKeepOrReplaceCoverImage() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-cover-character", + name = "v2-audio-update-cover-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "cover-update-content") + val oldCover = content.coverImage + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/cover-update-content.mp3") + + 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())) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + assertEquals(oldCover, entityManager.find(AudioContent::class.java, content.id).coverImage) + + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/cover-updated")) + 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())) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + val updatedCover = entityManager.find(AudioContent::class.java, content.id).coverImage!! + assertTrue(updatedCover.startsWith("audio_content_cover/${content.id}/")) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("수정은 빈 coverImage를 생략과 동일하게 처리한다") + fun shouldTreatEmptyCoverImageAsAbsent() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-empty-cover-character", + name = "v2-audio-update-empty-cover-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "empty-cover-update-content") + val oldCover = content.coverImage + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/empty-cover-update-content.mp3") + + 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())) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + assertEquals(oldCover, entityManager.find(AudioContent::class.java, content.id).coverImage) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("수정은 빈 audioFile part도 교체 요청으로 거부한다") + fun shouldRejectEmptyAudioFileUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-empty-audio-character", + name = "v2-audio-update-empty-audio-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "empty-audio-update-content") + 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( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + """{"title":"should not change"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(oldTitle, 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("수정은 cover 업로드 실패 시 DB를 변경하지 않는다") + fun shouldKeepDatabaseStateWhenCoverUploadFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-cover-failure-character", + name = "v2-audio-update-cover-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "cover-failure-content") + val oldCover = content.coverImage + entityManager.flush() + Mockito.doThrow(IllegalStateException("cover upload failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + + 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, + """{"title":"cover failure after title"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + + val saved = entityManager.find(AudioContent::class.java, content.id) + assertEquals("cover-failure-content", saved.title) + assertEquals(oldCover, saved.coverImage) + } + + 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, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + releaseDate = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime() + duration = "01:10:00" + content = "private/$title.mp3" + coverImage = "cover/$title.png" + entityManager.persist(this) + } + } + + private fun saveSeries(title: String, owner: Member): Series { + val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true) + entityManager.persist(genre) + return Series(title = title, introduction = "introduction", languageCode = "ko").apply { + member = owner + this.genre = genre + coverImage = "$title.png" + entityManager.persist(this) + } + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + return SeriesContent().apply { + this.series = series + this.content = content + entityManager.persist(this) + } + } + + private fun findSeriesContents(contentId: Long): List { + return entityManager.createQuery( + "select sc from SeriesContent sc where sc.content.id = :contentId", + SeriesContent::class.java + ).setParameter("contentId", contentId).resultList + } + + private fun replaceCreatorAdminContentServicePublisher( + publisher: ApplicationEventPublisher + ): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(creatorAdminContentService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualCreatorAdminContentServicePublisher(publisher: ApplicationEventPublisher) { + val target = AopTestUtils.getTargetObject(creatorAdminContentService) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt new file mode 100644 index 00000000..1ff7deea --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt @@ -0,0 +1,70 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +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 java.net.URI +import java.nio.file.Files +import java.security.KeyPairGenerator +import java.util.Base64 + +class AudioContentCloudFrontCharacterizationTest { + @Test + @DisplayName("기존 AudioContentCloudFront는 요청한 밀리초 TTL과 중첩 경로로 signed URL을 만든다") + fun shouldUseRequestedTtlAndNestedResourcePath() { + withCloudFront { cloudFront, privateKeyMaterial -> + val before = System.currentTimeMillis() / 1_000 + + val signedUrl = cloudFront.generateSignedURL("audio/nested/clip.mp3", 3_600_000L) + + val after = System.currentTimeMillis() / 1_000 + val uri = URI(signedUrl) + val expires = queryParameter(uri, "Expires").toLong() + assertTrue(expires in (before + 3_599)..(after + 3_601)) + assertEquals("/audio/nested/clip.mp3", uri.path) + assertFalse(signedUrl.contains(privateKeyMaterial)) + assertFalse(signedUrl.contains("BEGIN PRIVATE KEY")) + } + } + + @Test + @DisplayName("기존 AudioContentCloudFront는 선행 slash를 정규화하지 않고 즉시 만료 URL도 서명한다") + fun shouldPreserveLeadingSlashAndAllowZeroTtl() { + withCloudFront { cloudFront, _ -> + val before = System.currentTimeMillis() / 1_000 + + val signedUrl = cloudFront.generateSignedURL("/audio/edge.mp3", 0L) + + val after = System.currentTimeMillis() / 1_000 + val uri = URI(signedUrl) + val expires = queryParameter(uri, "Expires").toLong() + assertEquals("//audio/edge.mp3", uri.path) + assertTrue(expires in (before - 1)..(after + 1)) + } + } + + private fun withCloudFront(block: (AudioContentCloudFront, String) -> Unit) { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1_024) }.generateKeyPair() + val keyFile = Files.createTempFile("audio-content-cloudfront", ".der") + Files.write(keyFile, keyPair.private.encoded) + + try { + block( + AudioContentCloudFront("https://cdn.example.com", keyFile.toString(), "test-key-pair"), + Base64.getEncoder().encodeToString(keyPair.private.encoded) + ) + } finally { + Files.deleteIfExists(keyFile) + } + } + + private fun queryParameter(uri: URI, name: String): String { + return uri.rawQuery + .split("&") + .map { it.split("=", limit = 2) } + .single { it[0] == name }[1] + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt new file mode 100644 index 00000000..69b2af34 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt @@ -0,0 +1,251 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.ContentPriceChangeLogRepository +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentRepository +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor( + private val audioContentService: AudioContentService, + private val repository: CreatorAdminContentRepository, + private val hashTagRepository: HashTagRepository, + private val contentPriceChangeLogRepository: ContentPriceChangeLogRepository, + private val entityManager: EntityManager +) { + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + private lateinit var s3Uploader: S3Uploader + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var service: CreatorAdminContentService + + @BeforeEach + fun setUp() { + registerMysqlDateFunctions() + audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/test")) + s3Uploader = S3Uploader(amazonS3Client) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + service = CreatorAdminContentService( + repository = repository, + hashTagRepository = hashTagRepository, + contentPriceChangeLogRepository = contentPriceChangeLogRepository, + audioContentCloudFront = audioContentCloudFront, + objectMapper = jacksonObjectMapper(), + s3Uploader = s3Uploader, + applicationEventPublisher = applicationEventPublisher, + bucket = "test-bucket", + coverImageHost = "https://cover.example.com" + ) + } + + @Test + @DisplayName("기존 콘텐츠 생성은 표지와 오디오를 업로드하고 처리 대기 콘텐츠를 저장한다") + fun shouldCreateContentThroughLegacyUploadPipeline() { + val owner = saveMember("legacy-create-owner") + val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + + val response = audioContentService.createAudioContent( + contentFile = MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1, 2, 3)), + coverImage = MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(4, 5, 6)), + requestString = """ + {"title":"legacy created audio","detail":"created detail","tags":"#legacy","price":100,"themeId":${theme.id},"isAdult":false,"languageCode":"ko"} + """.trimIndent(), + member = owner + ) + entityManager.flush() + + val content = entityManager.find(AudioContent::class.java, response.contentId) + assertEquals("legacy created audio", content.title) + assertEquals(owner.id, content.member!!.id) + assertEquals(theme.id, content.theme!!.id) + assertFalse(content.isActive) + assertTrue(content.coverImage!!.startsWith("audio_content_cover/${response.contentId}/")) + assertTrue(content.content!!.startsWith("input/${response.contentId}/")) + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any()) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 목록과 검색은 소유자, 처리 완료, 예약 상태를 구분하고 signed URL로 교체한다") + fun shouldListAndSearchOnlyOwnersProcessedOrReservedContentWithSignedUrl() { + val owner = saveMember("legacy-content-owner") + val otherOwner = saveMember("legacy-content-other-owner") + val published = saveAudioContent(owner, "published legacy audio", "01:10:00") + val reserved = saveAudioContent(owner, "reserved legacy audio", "00:10:00").apply { + isActive = false + releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0) + } + saveAudioContent(owner, "processing legacy audio", "00:10:00").duration = null + saveAudioContent(otherOwner, "published legacy audio", "01:10:00") + entityManager.flush() + entityManager.clear() + + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + val list = service.getAudioContentList(PageRequest.of(0, 10), owner) + val search = service.searchAudioContent("published", owner, PageRequest.of(0, 10)) + + assertEquals(2, list.totalCount) + assertEquals(setOf(published.id, reserved.id), list.items.map { it.audioContentId }.toSet()) + assertEquals(1, search.totalCount) + assertEquals(listOf(published.id), search.items.map { it.audioContentId }) + assertEquals( + "https://signed.example.com/${published.content}?Expires=1", + list.items.single { it.audioContentId == published.id }.contentUrl + ) + assertNotEquals(published.content, list.items.single { it.audioContentId == published.id }.contentUrl) + Mockito.verify(audioContentCloudFront, Mockito.times(2)) + .generateSignedURL(published.content!!, 10_800_000L) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 검색은 한 글자 검색어를 거부한다") + fun shouldRejectOneCharacterSearchWord() { + val owner = saveMember("legacy-search-owner") + + val exception = assertThrows(SodaException::class.java) { + service.searchAudioContent("a", owner, PageRequest.of(0, 10)) + } + + assertEquals("creator.admin.content.search_word_min_length", exception.messageKey) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 수정은 가격과 플래그를 저장하고 비활성화 시 예약을 해제하며 번역 이벤트를 발행한다") + fun shouldUpdatePriceFlagsSoftDeleteAndTranslationEvent() { + val owner = saveMember("legacy-update-owner") + val content = saveAudioContent(owner, "before title", "00:10:00").apply { + price = 100 + releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0) + } + entityManager.flush() + + service.updateAudioContent( + coverImage = null, + requestString = """ + {"id":${content.id},"title":"after title","detail":"after detail","tags":"#updated updated","price":250,"isAdult":true,"isPointAvailable":true,"isCommentAvailable":false,"isActive":false} + """.trimIndent(), + member = owner + ) + entityManager.flush() + + assertEquals("after title", content.title) + assertEquals("after detail", content.detail) + assertEquals(250, content.price) + assertTrue(content.isAdult) + assertTrue(content.isPointAvailable) + assertFalse(content.isCommentAvailable) + assertFalse(content.isActive) + assertNull(content.releaseDate) + assertEquals(100, contentPriceChangeLogRepository.findAll().single().prevPrice) + assertEquals(listOf("#updated"), content.audioContentHashTags.map { it.hashTag!!.tag }) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageTranslationEvent + assertEquals(content.id, event.id) + assertEquals(LanguageTranslationTargetType.CONTENT, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 표지 수정은 생성된 uploader 객체 경로를 저장한다") + fun shouldStoreGeneratedUploaderCoverPath() { + val owner = saveMember("legacy-cover-owner") + val content = saveAudioContent(owner, "cover legacy audio", "00:10:00") + + service.updateAudioContent( + coverImage = MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)), + requestString = """{"id":${content.id}}""", + member = owner + ) + + assertTrue(content.coverImage!!.startsWith("audio_content_cover/${content.id}/")) + } + + private fun saveMember(nickname: String): Member { + val member = Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.CREATOR + ) + entityManager.persist(member) + entityManager.flush() + return member + } + + private fun saveAudioContent(owner: Member, title: String, duration: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5 + ).apply { + member = owner + this.theme = theme + isActive = true + releaseDate = LocalDateTime.of(2026, 7, 24, 10, 0) + this.duration = duration + content = "audio/${title.replace(" ", "-")}.mp3" + coverImage = "audio/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + 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() + } +}