test #443

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

View File

@@ -220,9 +220,6 @@ class AudioContentService(
// request 내용 파싱 // request 내용 파싱
val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java)
// 미리듣기 시간 체크
validatePreviewTime(request.previewStartTime, request.previewEndTime)
val releaseDate = if (request.releaseDate != null) { val releaseDate = if (request.releaseDate != null) {
request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm") request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm")
.atZone(ZoneId.of(request.timezone)) .atZone(ZoneId.of(request.timezone))
@@ -232,11 +229,26 @@ class AudioContentService(
LocalDateTime.now() LocalDateTime.now()
} }
return createAudioContent(contentFile, coverImage, request, releaseDate, member)
}
@Transactional
fun createAudioContent(
contentFile: MultipartFile?,
coverImage: MultipartFile?,
request: CreateAudioContentRequest,
releaseDate: LocalDateTime,
member: Member
): CreateAudioContentResponse {
if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required")
// contentFile 체크 // contentFile 체크
if (contentFile == null) { if (contentFile == null) {
throw SodaException(messageKey = "content.error.content_required") throw SodaException(messageKey = "content.error.content_required")
} }
validatePreviewTime(request.previewStartTime, request.previewEndTime)
// 테마 체크 // 테마 체크
val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId)
?: throw SodaException(messageKey = "content.error.invalid_theme") ?: throw SodaException(messageKey = "content.error.invalid_theme")

View File

@@ -1,16 +1,23 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.util.MultiValueMap
import org.springframework.web.HttpMediaTypeNotSupportedException
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController @RestController
@RequestMapping("/api/v2/admin/ai-characters") @RequestMapping("/api/v2/admin/ai-characters")
@@ -25,30 +32,33 @@ class AiCharacterAdminAudioContentController(
@GetMapping("/{characterId:[0-9]+}/audio-contents") @GetMapping("/{characterId:[0-9]+}/audio-contents")
fun list( fun list(
@PathVariable characterId: Long, @PathVariable characterId: Long,
@RequestParam(required = false) search: String?, @RequestParam(value = "search_word", required = false) searchWord: String?,
@RequestParam(required = false) status: String?,
@RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int @RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentListResponse> { ): ApiResponse<AiCharacterAdminAudioContentListResponse> {
return ApiResponse.ok(facade.list(characterId, search, status, page, size)) return ApiResponse.ok(facade.list(characterId, searchWord, page, size))
} }
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}") @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}")
fun detail( fun detail(
@PathVariable characterId: Long, @PathVariable characterId: Long,
@PathVariable contentId: Long @PathVariable contentId: Long,
@RequestParam queryParameters: MultiValueMap<String, String>
): ApiResponse<AiCharacterAdminAudioContentResponse> { ): ApiResponse<AiCharacterAdminAudioContentResponse> {
return ApiResponse.ok(facade.detail(characterId, contentId)) return ApiResponse.ok(facade.detail(characterId, contentId, queryParameters.keys))
} }
@PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create( fun create(
@PathVariable characterId: Long, @PathVariable characterId: Long,
@RequestPart("coverImage") coverImage: MultipartFile, @RequestPart("coverImage") coverImage: MultipartFile,
@RequestPart("audioFile") audioFile: MultipartFile, @RequestPart("contentFile") contentFile: MultipartFile,
@RequestPart("request") request: String @RequestPart("request") request: String,
): ApiResponse<AiCharacterAdminAudioContentResponse> { multipartRequest: MultipartHttpServletRequest
return ApiResponse.ok(facade.create(characterId, coverImage, audioFile, request)) ): ApiResponse<AiCharacterAdminAudioContentCreateResponse> {
requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
return ApiResponse.ok(facade.create(characterId, coverImage, contentFile, request))
} }
@PutMapping( @PutMapping(
@@ -59,9 +69,96 @@ class AiCharacterAdminAudioContentController(
@PathVariable characterId: Long, @PathVariable characterId: Long,
@PathVariable contentId: Long, @PathVariable contentId: Long,
@RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?, @RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?,
@RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?, @RequestPart("request") request: String,
@RequestPart("request") request: String multipartRequest: MultipartHttpServletRequest
): ApiResponse<AiCharacterAdminAudioContentResponse> { ): ApiResponse<Nothing> {
return ApiResponse.ok(facade.update(characterId, contentId, coverImage, audioFile, request)) requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, contentId, coverImage, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(
multipartRequest: MultipartHttpServletRequest,
allowedParts: Set<String>
) {
if (multipartRequest.fileMap.keys.any { it !in allowedParts } ||
multipartRequest.parts.any { it.name !in allowedParts }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val CREATE_MULTIPART_PARTS = setOf("coverImage", "contentFile", "request")
private val UPDATE_MULTIPART_PARTS = setOf("coverImage", "request")
}
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments")
fun comments(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestParam queryParameters: MultiValueMap<String, String>,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentCommentListResponse> {
return ApiResponse.ok(facade.comments(characterId, contentId, queryParameters.keys, page, size))
}
@PostMapping(
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments",
consumes = [MediaType.APPLICATION_JSON_VALUE]
)
fun createComment(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.createComment(characterId, contentId, request)
return ApiResponse.ok(null)
}
@PutMapping(
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}",
consumes = [MediaType.APPLICATION_JSON_VALUE]
)
fun updateComment(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@PathVariable commentId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateComment(characterId, contentId, commentId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}")
fun deleteComment(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@PathVariable commentId: Long
): ApiResponse<Nothing> {
facade.deleteComment(characterId, contentId, commentId)
return ApiResponse.ok(null)
}
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}/replies")
fun replies(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@PathVariable commentId: Long,
@RequestParam queryParameters: MultiValueMap<String, String>,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentCommentListResponse> {
return ApiResponse.ok(facade.replies(characterId, contentId, commentId, queryParameters.keys, page, size))
} }
} }

View File

@@ -1,126 +1,31 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
import kr.co.vividnext.sodalive.content.CreateAudioContentResponse
import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse
import kr.co.vividnext.sodalive.content.PurchaseOption import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListItem
import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListResponse
data class AiCharacterAdminAudioContentListResponse( typealias AiCharacterAdminAudioContentListResponse = GetCreatorAdminContentListResponse
val totalCount: Long, typealias AiCharacterAdminAudioContentListItem = GetCreatorAdminContentListItem
val items: List<AiCharacterAdminAudioContentListItem>, typealias AiCharacterAdminAudioContentResponse = GetAudioContentDetailResponse
val page: Int, typealias AiCharacterAdminAudioContentThemeResponse = GetAudioContentThemeResponse
val size: Int, typealias AiCharacterAdminAudioContentCreateResponse = CreateAudioContentResponse
val hasNext: Boolean typealias AiCharacterAdminAudioContentCommentListResponse = GetAudioContentCommentListResponse
)
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<AiCharacterAdminOtherContentResponse>,
val sameThemeOtherContentList: List<AiCharacterAdminOtherContentResponse>,
val isLike: Boolean,
val likeCount: Int,
val commentList: List<AiCharacterAdminAudioContentCommentResponse>,
val commentCount: Int,
val isPin: Boolean,
val isAvailablePin: Boolean,
val creator: AiCharacterAdminAudioContentCreatorResponse,
val previousContent: AiCharacterAdminOtherContentResponse?,
val nextContent: AiCharacterAdminOtherContentResponse?,
val buyerList: List<AiCharacterAdminContentBuyerResponse>,
val isAvailableUsePoint: Boolean,
val translated: AiCharacterAdminTranslatedContentResponse?,
val status: AiCharacterAdminAudioContentStatus,
val seriesIds: List<Long>,
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( data class AiCharacterAdminAudioContentCreateRequest(
val title: String, val title: String,
val description: String, val detail: String,
val tags: String,
val price: Int, val price: Int,
val isAdult: Boolean = false,
val isActive: Boolean = true,
val themeId: Long,
val tags: String = "",
val purchaseOption: PurchaseOption = PurchaseOption.BOTH, val purchaseOption: PurchaseOption = PurchaseOption.BOTH,
val limited: Int? = null, val limited: Int? = null,
val releaseDateUtc: String? = null, val releaseDate: String? = null,
val themeId: Long = 0,
val isAdult: Boolean = false,
val isGeneratePreview: Boolean = false, val isGeneratePreview: Boolean = false,
val isOnlyRental: Boolean = false, val isOnlyRental: Boolean = false,
val isPointAvailable: Boolean = false, val isPointAvailable: Boolean = false,
@@ -128,24 +33,38 @@ data class AiCharacterAdminAudioContentCreateRequest(
val isFullDetailVisible: Boolean = true, val isFullDetailVisible: Boolean = true,
val previewStartTime: String? = null, val previewStartTime: String? = null,
val previewEndTime: String? = null, val previewEndTime: String? = null,
val languageCode: String? = null, val languageCode: String? = null
val seriesIds: List<Long> = emptyList() ) {
fun toLegacyRequest(): CreateAudioContentRequest {
return CreateAudioContentRequest(
title = title,
detail = detail,
tags = tags,
price = price,
purchaseOption = purchaseOption,
limited = limited,
timezone = "UTC",
themeId = themeId,
isAdult = isAdult,
isGeneratePreview = isGeneratePreview,
isOnlyRental = isOnlyRental,
isPointAvailable = isPointAvailable,
isCommentAvailable = isCommentAvailable,
isFullDetailVisible = isFullDetailVisible,
previewStartTime = previewStartTime,
previewEndTime = previewEndTime,
languageCode = languageCode
) )
}
}
data class AiCharacterAdminAudioContentUpdateRequest( data class AiCharacterAdminAudioContentUpdateRequest(
val title: String? = null, val title: String? = null,
val description: String? = null, val detail: String? = null,
val tags: String? = null, val tags: String? = null,
val price: Int? = null, val price: Int? = null,
val isAdult: Boolean? = null, val isAdult: Boolean? = null,
val isActive: Boolean? = null, val isActive: Boolean? = null,
val releaseDateUtc: String? = null,
val seriesIds: List<Long>? = null,
val isPointAvailable: Boolean? = null, val isPointAvailable: Boolean? = null,
val isCommentAvailable: Boolean? = null val isCommentAvailable: Boolean? = null
) )
enum class AiCharacterAdminAudioContentStatus {
OPEN,
SCHEDULED
}

View File

@@ -1,12 +1,19 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse
import kr.co.vividnext.sodalive.content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.content.comment.RegisterCommentRequest
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
import kr.co.vividnext.sodalive.creator.admin.content.UpdateCreatorAdminContentRequest
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
@@ -14,10 +21,10 @@ import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.DateTimeException
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@Service @Service
class AiCharacterAdminAudioContentFacade( class AiCharacterAdminAudioContentFacade(
@@ -27,104 +34,148 @@ class AiCharacterAdminAudioContentFacade(
private val themeQueryRepository: AudioContentThemeQueryRepository, private val themeQueryRepository: AudioContentThemeQueryRepository,
private val mapper: AiCharacterAdminAudioContentMapper, private val mapper: AiCharacterAdminAudioContentMapper,
private val audioContentService: AudioContentService, private val audioContentService: AudioContentService,
private val creatorAdminContentService: CreatorAdminContentService private val creatorAdminContentService: CreatorAdminContentService,
private val audioContentCommentService: AudioContentCommentService
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun themes(): List<AiCharacterAdminAudioContentThemeResponse> { fun themes(): List<AiCharacterAdminAudioContentThemeResponse> {
return themeQueryRepository.getActiveThemes().map { return themeQueryRepository.getActiveThemes()
AiCharacterAdminAudioContentThemeResponse(
themeId = it.id,
themeName = it.theme,
imageUrl = it.image
)
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun list( fun list(
characterId: Long, characterId: Long,
search: String?, searchWord: String?,
status: String?,
page: Int, page: Int,
size: Int size: Int
): AiCharacterAdminAudioContentListResponse { ): AiCharacterAdminAudioContentListResponse {
val target = targetResolver.resolve(characterId) val target = targetResolver.resolve(characterId)
val normalizedSearch = search?.trim().orEmpty()
if (normalizedSearch.length == 1) throw invalidRequest()
val normalizedPage = page.coerceAtLeast(0) val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE) val pageable = PageRequest.of(normalizedPage, size.coerceAtLeast(MINIMUM_PAGE_SIZE))
val contents = repository.findPage( return if (searchWord == null) {
creatorMemberId = target.creatorMember.id ?: throw invalidRequest(), creatorAdminContentService.getAudioContentList(pageable, target.creatorMember)
search = normalizedSearch, } else {
status = parseStatus(status), creatorAdminContentService.searchAudioContent(searchWord, target.creatorMember, pageable)
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) @Transactional(readOnly = true)
fun detail(characterId: Long, contentId: Long): AiCharacterAdminAudioContentResponse { fun detail(
characterId: Long,
contentId: Long,
queryParameterNames: Set<String>
): AiCharacterAdminAudioContentResponse {
if (queryParameterNames.isNotEmpty()) throw invalidRequest()
val target = targetResolver.resolve(characterId) val target = targetResolver.resolve(characterId)
val content = repository.findByIdAndCreatorMemberId( val content = repository.findByIdAndCreatorMemberId(
contentId = contentId, contentId = contentId,
creatorMemberId = target.creatorMember.id ?: throw invalidRequest() creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
) ?: throw invalidRequest() ) ?: throw invalidRequest()
return mapper.toResponse(content, target.creatorMember.id ?: throw invalidRequest()) return mapper.toResponse(content)
}
@Transactional(readOnly = true)
fun comments(
characterId: Long,
contentId: Long,
queryParameterNames: Set<String>,
page: Int,
size: Int
): GetAudioContentCommentListResponse {
val target = resolveOwnedActiveContent(characterId, contentId)
validateCommentQuery(queryParameterNames, page, size)
return utcCommentDates(
audioContentCommentService.getCommentList(
audioContentId = contentId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
pageable = PageRequest.of(page, size)
),
contentId
)
}
@Transactional
fun createComment(characterId: Long, contentId: Long, requestString: String) {
val target = resolveOwnedActiveContent(characterId, contentId)
val request = readCommentCreateRequest(requestString, contentId)
request.parentId?.let { parentId ->
repository.findActiveRootCommentByIdAndContentId(parentId, contentId) ?: throw invalidRequest()
}
audioContentCommentService.registerComment(
member = target.creatorMember,
comment = request.comment,
audioContentId = contentId,
parentId = request.parentId,
isSecret = request.isSecret,
languageCode = request.languageCode
)
}
@Transactional
fun updateComment(characterId: Long, contentId: Long, commentId: Long, requestString: String) {
val target = resolveOwnedActiveContent(characterId, contentId)
val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest()
val request = readCommentUpdateRequest(requestString, commentId)
audioContentCommentService.modifyComment(request, target.creatorMember)
}
@Transactional
fun deleteComment(characterId: Long, contentId: Long, commentId: Long) {
val target = resolveOwnedActiveContent(characterId, contentId)
val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
if (!comment.isActive) return
audioContentCommentService.modifyComment(
ModifyCommentRequest(commentId = commentId, isActive = false),
target.creatorMember
)
}
@Transactional(readOnly = true)
fun replies(
characterId: Long,
contentId: Long,
commentId: Long,
queryParameterNames: Set<String>,
page: Int,
size: Int
): GetAudioContentCommentListResponse {
val target = resolveOwnedActiveContent(characterId, contentId)
validateCommentQuery(queryParameterNames, page, size)
repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
return utcCommentDates(
audioContentCommentService.getCommentReplyList(
commentId = commentId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
pageable = PageRequest.of(page, size)
),
contentId
)
} }
@Transactional @Transactional
fun create( fun create(
characterId: Long, characterId: Long,
coverImage: MultipartFile, coverImage: MultipartFile,
audioFile: MultipartFile, contentFile: MultipartFile,
requestString: String requestString: String
): AiCharacterAdminAudioContentResponse { ): AiCharacterAdminAudioContentCreateResponse {
val target = targetResolver.resolve(characterId) val target = targetResolver.resolve(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() if (coverImage.isEmpty || contentFile.isEmpty) throw invalidRequest()
if (coverImage.isEmpty || audioFile.isEmpty) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java) val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java)
if (!request.isActive) throw invalidRequest() val releaseDate = parseUtcReleaseDate(request.releaseDate) ?: LocalDateTime.now(ZoneOffset.UTC)
validateSeriesIds(request.seriesIds, creatorMemberId) return audioContentService.createAudioContent(
val created = audioContentService.createAudioContent( contentFile = contentFile,
contentFile = audioFile,
coverImage = coverImage, coverImage = coverImage,
requestString = objectMapper.writeValueAsString( request = request.toLegacyRequest(),
CreateAudioContentRequest( releaseDate = releaseDate,
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 member = target.creatorMember
) )
val content = repository.findByIdAndCreatorMemberId(created.contentId, creatorMemberId) ?: throw invalidRequest()
replaceSeriesIds(content, request.seriesIds, creatorMemberId)
return mapper.toResponse(content, creatorMemberId)
} }
@Transactional @Transactional
@@ -132,27 +183,21 @@ class AiCharacterAdminAudioContentFacade(
characterId: Long, characterId: Long,
contentId: Long, contentId: Long,
coverImage: MultipartFile?, coverImage: MultipartFile?,
audioFile: MultipartFile?,
requestString: String requestString: String
): AiCharacterAdminAudioContentResponse { ) {
val target = targetResolver.resolve(characterId) val target = targetResolver.resolve(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest() repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest()
if (audioFile != null) throw invalidRequest()
val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty } val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty }
val request = readRequest(requestString, AiCharacterAdminAudioContentUpdateRequest::class.java) 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( creatorAdminContentService.updateAudioContent(
coverImage = normalizedCoverImage, coverImage = normalizedCoverImage,
requestString = objectMapper.writeValueAsString( requestString = objectMapper.writeValueAsString(
CreatorAdminAudioContentUpdatePayload( UpdateCreatorAdminContentRequest(
id = contentId, id = contentId,
title = request.title, title = request.title,
detail = request.description, detail = request.detail,
tags = request.tags, tags = request.tags,
price = request.price, price = request.price,
isAdult = request.isAdult, isAdult = request.isAdult,
@@ -163,60 +208,95 @@ class AiCharacterAdminAudioContentFacade(
), ),
member = target.creatorMember 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<Long>, creatorMemberId: Long) {
try {
repository.replaceSeriesIds(content, seriesIds, creatorMemberId)
} catch (_: IllegalArgumentException) {
throw invalidRequest()
}
}
private fun validateSeriesIds(seriesIds: List<Long>, 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 <T> readRequest(requestString: String, requestClass: Class<T>): T { private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try { return try {
objectMapper.readValue(requestString, requestClass) objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
.readValue(requestString)
} catch (_: JsonProcessingException) { } catch (_: JsonProcessingException) {
throw invalidRequest() throw invalidRequest()
} }
} }
private fun hasField(requestString: String, fieldName: String): Boolean { private fun parseUtcReleaseDate(releaseDate: String?): LocalDateTime? {
if (releaseDate == null) return null
if (!releaseDate.endsWith("Z")) throw invalidRequest()
return try { return try {
objectMapper.readTree(requestString).has(fieldName) LocalDateTime.ofInstant(Instant.parse(releaseDate), ZoneOffset.UTC)
} catch (_: JsonProcessingException) { } catch (_: DateTimeException) {
throw invalidRequest() throw invalidRequest()
} }
} }
private fun String.toLegacyReleaseDate(): String { private fun resolveOwnedActiveContent(characterId: Long, contentId: Long): AiCharacterAdminTarget {
return toUtcLocalDateTime().format(LEGACY_RELEASE_DATE_FORMATTER) val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(
contentId = contentId,
creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
) ?: throw invalidRequest()
return target
} }
private fun String.toUtcLocalDateTime(): LocalDateTime { private fun validateCommentQuery(queryParameterNames: Set<String>, page: Int, size: Int) {
if (!COMMENT_QUERY_PARAMETERS.containsAll(queryParameterNames) || page < 0 || size < MINIMUM_PAGE_SIZE) {
throw invalidRequest()
}
}
private fun utcCommentDates(
response: GetAudioContentCommentListResponse,
contentId: Long
): GetAudioContentCommentListResponse {
return response.copy(items = response.items.map { it.withUtcDate(contentId) })
}
private fun GetAudioContentCommentListItem.withUtcDate(contentId: Long): GetAudioContentCommentListItem {
val createdAt = repository.findCommentByIdAndContentId(id, contentId)?.createdAt ?: throw invalidRequest()
return copy(date = createdAt.toUtcIso())
}
private fun readCommentCreateRequest(requestString: String, contentId: Long): RegisterCommentRequest {
val serializedRequest = injectCommentPathId(
requestString = requestString,
allowedFields = CREATE_COMMENT_FIELDS,
idField = "contentId",
id = contentId
)
return readRequest(serializedRequest, RegisterCommentRequest::class.java)
}
private fun readCommentUpdateRequest(requestString: String, commentId: Long): ModifyCommentRequest {
val serializedRequest = injectCommentPathId(
requestString = requestString,
allowedFields = UPDATE_COMMENT_FIELDS,
idField = "commentId",
id = commentId
)
val request = readRequest(serializedRequest, ModifyCommentRequest::class.java)
if (request.comment == null) throw invalidRequest()
return request
}
private fun injectCommentPathId(
requestString: String,
allowedFields: Set<String>,
idField: String,
id: Long
): String {
return try { return try {
Instant.parse(this).atOffset(ZoneOffset.UTC).toLocalDateTime() val request = objectMapper.readTree(requestString) as? com.fasterxml.jackson.databind.node.ObjectNode
} catch (_: RuntimeException) { ?: throw invalidRequest()
if (request.fieldNames().asSequence().any { it !in allowedFields } || !request.hasNonNull("comment")) {
throw invalidRequest()
}
request.put(idField, id)
objectMapper.writeValueAsString(request)
} catch (_: JsonProcessingException) {
throw invalidRequest() throw invalidRequest()
} }
} }
@@ -226,21 +306,10 @@ class AiCharacterAdminAudioContentFacade(
} }
companion object { companion object {
private const val MINIMUM_PAGE_SIZE = 20 private const val MINIMUM_PAGE_SIZE = 1
private const val MAXIMUM_PAGE_SIZE = 50
private const val UTC_TIMEZONE = "UTC" private const val UTC_TIMEZONE = "UTC"
private val LEGACY_RELEASE_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") private val COMMENT_QUERY_PARAMETERS = setOf("page", "size")
private val CREATE_COMMENT_FIELDS = setOf("comment", "parentId", "isSecret", "languageCode")
private val UPDATE_COMMENT_FIELDS = setOf("comment")
} }
private data class CreatorAdminAudioContentUpdatePayload(
val id: Long,
val title: String?,
val detail: String?,
val tags: String?,
val price: Int?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isPointAvailable: Boolean?,
val isCommentAvailable: Boolean?
)
} }

View File

@@ -2,58 +2,36 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentCreator
import kr.co.vividnext.sodalive.content.PurchaseOption import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@Component @Component
class AiCharacterAdminAudioContentMapper( class AiCharacterAdminAudioContentMapper(
private val repository: AiCharacterAdminAudioContentRepository,
private val audioContentCloudFront: AudioContentCloudFront, private val audioContentCloudFront: AudioContentCloudFront,
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) { ) {
fun toListItem(content: AudioContent): AiCharacterAdminAudioContentListItem { fun toResponse(content: AudioContent): AiCharacterAdminAudioContentResponse {
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 owner = content.member ?: throw invalidRequest()
val audioSignedUrl = signedUrl(content)
return AiCharacterAdminAudioContentResponse( return AiCharacterAdminAudioContentResponse(
contentId = content.id ?: throw invalidRequest(), contentId = content.id ?: throw invalidRequest(),
title = content.title, title = content.title,
description = content.detail,
detail = content.detail, detail = content.detail,
coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}", coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}",
audioSignedUrl = audioSignedUrl, contentUrl = signedUrl(content).orEmpty(),
contentUrl = audioSignedUrl,
languageCode = content.languageCode, languageCode = content.languageCode,
themeStr = content.theme?.theme ?: "", themeStr = content.theme?.theme ?: "",
tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "), tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "),
price = content.price, price = content.price,
duration = content.duration ?: "", duration = content.duration ?: "",
isAdult = content.isAdult, isAdult = content.isAdult,
isActive = content.isActive, releaseDate = releaseDate(content),
isPointAvailable = content.isPointAvailable,
isCommentAvailable = content.isCommentAvailable,
releaseDateUtc = releaseDateUtc,
releaseDate = null,
totalContentCount = content.limited, totalContentCount = content.limited,
remainingContentCount = content.remaining, remainingContentCount = content.remaining,
orderSequence = null, orderSequence = null,
@@ -66,13 +44,14 @@ class AiCharacterAdminAudioContentMapper(
remainingTime = null, remainingTime = null,
creatorOtherContentList = emptyList(), creatorOtherContentList = emptyList(),
sameThemeOtherContentList = emptyList(), sameThemeOtherContentList = emptyList(),
isCommentAvailable = content.isCommentAvailable,
isLike = false, isLike = false,
likeCount = 0, likeCount = 0,
commentList = emptyList(), commentList = emptyList(),
commentCount = 0, commentCount = 0,
isPin = false, isPin = false,
isAvailablePin = false, isAvailablePin = false,
creator = AiCharacterAdminAudioContentCreatorResponse( creator = AudioContentCreator(
creatorId = owner.id ?: throw invalidRequest(), creatorId = owner.id ?: throw invalidRequest(),
nickname = owner.nickname, nickname = owner.nickname,
profileImageUrl = if (owner.profileImage != null) { profileImageUrl = if (owner.profileImage != null) {
@@ -88,11 +67,7 @@ class AiCharacterAdminAudioContentMapper(
nextContent = null, nextContent = null,
buyerList = emptyList(), buyerList = emptyList(),
isAvailableUsePoint = content.isPointAvailable, isAvailableUsePoint = content.isPointAvailable,
translated = null, translated = null
status = status(content),
seriesIds = repository.findSeriesIds(content.id ?: throw invalidRequest(), creatorMemberId),
createdAtUtc = content.createdAt?.toUtcIso(),
updatedAtUtc = content.updatedAt?.toUtcIso()
) )
} }
@@ -103,16 +78,10 @@ class AiCharacterAdminAudioContentMapper(
return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime) return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime)
} }
private fun status(content: AudioContent): AiCharacterAdminAudioContentStatus { private fun releaseDate(content: AudioContent): String? {
return if (content.releaseDate?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) { return content.releaseDate
AiCharacterAdminAudioContentStatus.SCHEDULED ?.takeIf { it > LocalDateTime.now(ZoneOffset.UTC) }
} else { ?.toUtcIso()
AiCharacterAdminAudioContentStatus.OPEN
}
}
private fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
} }
private fun invalidRequest(): AiCharacterAdminApiException { private fun invalidRequest(): AiCharacterAdminApiException {

View File

@@ -1,52 +1,17 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.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.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment
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 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 org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager
@Repository @Repository
class AiCharacterAdminAudioContentRepository( class AiCharacterAdminAudioContentRepository(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory
private val entityManager: EntityManager
) { ) {
fun findPage(
creatorMemberId: Long,
search: String,
status: AiCharacterAdminAudioContentStatus?,
pageable: Pageable
): Page<AudioContent> {
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? { fun findByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return queryFactory return queryFactory
.selectFrom(audioContent) .selectFrom(audioContent)
@@ -55,89 +20,37 @@ class AiCharacterAdminAudioContentRepository(
.fetchOne() .fetchOne()
} }
fun findSeriesIds(contentId: Long, creatorMemberId: Long): List<Long> { fun findActiveByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return queryFactory return queryFactory
.select(series.id) .selectFrom(audioContent)
.from(seriesContent) .innerJoin(audioContent.member, member)
.innerJoin(seriesContent.series, series)
.innerJoin(seriesContent.content, audioContent)
.where( .where(
audioContent.id.eq(contentId) audioContent.id.eq(contentId)
.and(audioContent.member.id.eq(creatorMemberId)) .and(member.id.eq(creatorMemberId))
.and(series.member.id.eq(creatorMemberId)) .and(audioContent.isActive.isTrue)
.and(series.isActive.isTrue)
) )
.fetch() .fetchOne()
} }
fun replaceSeriesIds(content: AudioContent, seriesIds: List<Long>, creatorMemberId: Long) { fun findCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? {
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<SeriesContent> {
return queryFactory return queryFactory
.selectFrom(seriesContent) .selectFrom(audioContentComment)
.innerJoin(seriesContent.series, series)
.where( .where(
seriesContent.content.id.eq(contentId) audioContentComment.id.eq(commentId)
.and(series.member.id.eq(creatorMemberId)) .and(audioContentComment.audioContent.id.eq(contentId))
) )
.fetch() .fetchOne()
} }
fun hasActiveSeriesIds(seriesIds: List<Long>, creatorMemberId: Long): Boolean { fun findActiveRootCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? {
return seriesIds.isEmpty() || findActiveSeriesByIds(seriesIds, creatorMemberId).size == seriesIds.distinct().size
}
private fun findActiveSeriesByIds(seriesIds: List<Long>, creatorMemberId: Long): List<Series> {
return queryFactory return queryFactory
.selectFrom(series) .selectFrom(audioContentComment)
.where( .where(
series.id.`in`(seriesIds) audioContentComment.id.eq(commentId)
.and(series.member.id.eq(creatorMemberId)) .and(audioContentComment.audioContent.id.eq(contentId))
.and(series.isActive.isTrue) .and(audioContentComment.parent.isNull)
.and(audioContentComment.isActive.isTrue)
) )
.fetch() .fetchOne()
}
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
} }
} }

View File

@@ -30,7 +30,10 @@ class H2MysqlDateFunctions {
.replace("%m", "MM") .replace("%m", "MM")
.replace("%d", "dd") .replace("%d", "dd")
.replace("%H", "HH") .replace("%H", "HH")
.replace("%W", "EEEE")
.replace("%h", "hh")
.replace("%i", "mm") .replace("%i", "mm")
.replace("%p", "a")
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
} }

View File

@@ -0,0 +1,387 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.MediaType
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminAudioContentCommentTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val entityManager: EntityManager
) {
@MockBean
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@Test
@DisplayName("원댓글과 답글 목록은 timezone 없이 UTC Z date와 기존 totalCount 및 items를 반환한다")
fun shouldListRootCommentsAndRepliesWithUtcDates() {
registerMysqlDateFunctions()
val character = createCharacter("comment-list")
val content = saveAudioContent(character.creatorMember!!, "comment-list-content")
val root = saveComment(content, character.creatorMember!!, "root")
val reply = saveComment(content, character.creatorMember!!, "reply", root)
root.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 1)
reply.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 2)
entityManager.flush()
mockMvc.perform(
get(commentsPath(character.id!!, content.id!!))
.param("page", "0")
.param("size", "10")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(root.id))
.andExpect(jsonPath("$.data.items[0].comment").value("root"))
.andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:01Z"))
mockMvc.perform(
get("${commentsPath(character.id!!, content.id!!)}/${root.id}/replies")
.param("page", "0")
.param("size", "10")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].comment").value("reply"))
.andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:02Z"))
}
@Test
@DisplayName("댓글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다")
fun shouldDefaultMissingCommentListPaginationParameters() {
registerMysqlDateFunctions()
val character = createCharacter("comment-default-page")
val content = saveAudioContent(character.creatorMember!!, "comment-default-page-content")
repeat(21) { index ->
saveComment(content, character.creatorMember!!, "root-$index")
}
entityManager.flush()
val path = commentsPath(character.id!!, content.id!!)
mockMvc.perform(get(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(21))
.andExpect(jsonPath("$.data.items.length()").value(20))
mockMvc.perform(get(path).param("page", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
mockMvc.perform(get(path).param("size", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
}
@Test
@DisplayName("답글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다")
fun shouldDefaultMissingReplyListPaginationParameters() {
registerMysqlDateFunctions()
val character = createCharacter("reply-default-page")
val content = saveAudioContent(character.creatorMember!!, "reply-default-page-content")
val root = saveComment(content, character.creatorMember!!, "root")
repeat(21) { index ->
saveComment(content, character.creatorMember!!, "reply-$index", root)
}
entityManager.flush()
val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}/replies"
mockMvc.perform(get(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(21))
.andExpect(jsonPath("$.data.items.length()").value(20))
mockMvc.perform(get(path).param("page", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
mockMvc.perform(get(path).param("size", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
}
@Test
@DisplayName("작성은 target AI를 writer로 사용하고 root와 답글 모두 data null을 반환한다")
fun shouldCreateRootAndReplyAsTargetAi() {
val character = createCharacter("comment-create")
val content = saveAudioContent(character.creatorMember!!, "comment-create-content")
entityManager.flush()
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"root", "languageCode":"ko"}""")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
val root = commentsFor(content.id!!).single()
assertEquals(character.creatorMember!!.id, root.member!!.id)
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"reply", "parentId":${root.id}, "isSecret":false, "languageCode":"ko"}""")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
val reply = commentsFor(content.id!!).single { it.parent?.id == root.id }
assertEquals(character.creatorMember!!.id, reply.member!!.id)
}
@Test
@DisplayName("다른 콘텐츠 또는 비활성 parent 답글 작성은 insert 없이 거부한다")
fun shouldRejectInvalidReplyParentWithoutInsert() {
val character = createCharacter("comment-parent")
val content = saveAudioContent(character.creatorMember!!, "comment-parent-content")
val otherContent = saveAudioContent(character.creatorMember!!, "comment-other-content")
val foreignParent = saveComment(otherContent, character.creatorMember!!, "other root")
val inactiveParent = saveComment(content, character.creatorMember!!, "inactive root").apply { isActive = false }
entityManager.flush()
val beforeCount = commentsFor(content.id!!).size
listOf(foreignParent.id!!, inactiveParent.id!!).forEach { parentId ->
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"must not persist", "parentId":$parentId}""")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
}
assertEquals(beforeCount, commentsFor(content.id!!).size)
}
@Test
@DisplayName("target AI가 작성하지 않은 활성 댓글 수정은 거부하고 원문을 유지한다")
fun shouldRejectFanAuthoredCommentUpdate() {
val character = createCharacter("comment-update")
val content = saveAudioContent(character.creatorMember!!, "comment-update-content")
val fanComment = saveComment(content, saveMember("comment-update-fan"), "fan comment")
entityManager.flush()
mockMvc.perform(
put("${commentsPath(character.id!!, content.id!!)}/${fanComment.id}")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"changed"}""")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals("fan comment", entityManager.find(AudioContentComment::class.java, fanComment.id).comment)
}
@Test
@DisplayName("target AI가 작성한 활성 댓글 수정은 data null로 완료한다")
fun shouldUpdateTargetAiAuthoredComment() {
val character = createCharacter("comment-update-target")
val content = saveAudioContent(character.creatorMember!!, "comment-update-target-content")
val comment = saveComment(content, character.creatorMember!!, "before")
entityManager.flush()
mockMvc.perform(
put("${commentsPath(character.id!!, content.id!!)}/${comment.id}")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"after"}""")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
assertEquals("after", entityManager.find(AudioContentComment::class.java, comment.id).comment)
}
@Test
@DisplayName("삭제는 대상 콘텐츠의 한 row만 비활성화하고 이미 비활성이면 성공 no-op이다")
fun shouldSoftDeleteOnlyTargetRowAndIgnoreInactiveComment() {
val character = createCharacter("comment-delete")
val content = saveAudioContent(character.creatorMember!!, "comment-delete-content")
val fan = saveMember("comment-delete-fan")
val root = saveComment(content, fan, "root")
val reply = saveComment(content, fan, "reply", root)
entityManager.flush()
val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}"
mockMvc.perform(delete(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
entityManager.flush()
entityManager.clear()
assertFalse(entityManager.find(AudioContentComment::class.java, root.id).isActive)
assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive)
mockMvc.perform(delete(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
entityManager.flush()
entityManager.clear()
assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive)
}
@Test
@DisplayName("잘못된 요청은 공통 400 envelope으로 변환한다")
fun shouldRejectInvalidCommentRequests() {
val character = createCharacter("comment-request")
val content = saveAudioContent(character.creatorMember!!, "comment-request-content")
entityManager.flush()
listOf("{}", "{\"comment\":", "{\"comment\":\"comment\",\"unknown\":true}").forEach { body ->
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
}
mockMvc.perform(
get(commentsPath(character.id!!, content.id!!))
.param("page", "-1")
.param("size", "0")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
mockMvc.perform(
get(commentsPath(character.id!!, content.id!!))
.param("timezone", "UTC")
.param("page", "0")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
}
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-$name-character",
name = name,
description = "description",
systemPrompt = "prompt"
).also {
it.creatorMember!!.profileImage = "profile/$name.png"
}
private fun saveAudioContent(owner: Member, title: String): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
entityManager.persist(theme)
return AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
price = 100,
isCommentAvailable = true
).apply {
member = owner
this.theme = theme
isActive = true
entityManager.persist(this)
}
}
private fun saveComment(
content: AudioContent,
writer: Member,
comment: String,
parent: AudioContentComment? = null
): AudioContentComment {
return AudioContentComment(comment = comment, languageCode = "ko").apply {
audioContent = content
member = writer
this.parent = parent
entityManager.persist(this)
}
}
private fun saveMember(nickname: String): Member {
return Member(
email = "$nickname@example.com",
password = "password",
nickname = nickname,
role = MemberRole.USER
).apply(entityManager::persist)
}
private fun commentsFor(contentId: Long): List<AudioContentComment> {
entityManager.flush()
return entityManager.createQuery(
"select comment from AudioContentComment comment where comment.audioContent.id = :contentId",
AudioContentComment::class.java
).setParameter("contentId", contentId).resultList
}
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
private fun commentsPath(characterId: Long, contentId: Long): String {
return "/api/v2/admin/ai-characters/$characterId/audio-contents/$contentId/comments"
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
}

View File

@@ -24,6 +24,7 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -46,6 +47,7 @@ import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -80,6 +82,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
@BeforeEach @BeforeEach
fun replaceActualServicePublishers() { fun replaceActualServicePublishers() {
registerMysqlDateFunctions()
originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher) originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher)
originalCreatorAdminContentServicePublisher = replacePublisher( originalCreatorAdminContentServicePublisher = replacePublisher(
creatorAdminContentService, creatorAdminContentService,
@@ -94,8 +97,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("생성은 themeId, 예약일, seriesIds를 포함한 multipart 요청으로 target 소유 콘텐츠 상세를 반환한다") @DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다")
fun shouldCreateOwnedContentWithThemeIdReleaseDateAndSeriesIds() { fun shouldCreateOwnedContentWithUtcMultipartRequest() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-character", characterUUID = "v2-audio-create-character",
name = "v2-audio-create-character", name = "v2-audio-create-character",
@@ -104,7 +107,6 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
) )
val theme = AudioContentTheme(theme = "create-theme", image = "theme.png") val theme = AudioContentTheme(theme = "create-theme", image = "theme.png")
entityManager.persist(theme) entityManager.persist(theme)
val series = saveSeries("create-series", character.creatorMember!!)
entityManager.flush() entityManager.flush()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://s3.example.com/test")) .thenReturn(URL("https://s3.example.com/test"))
@@ -112,14 +114,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE,
""" """
{"title":"created audio","description":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"isAdult":false,"isActive":true,"themeId":${theme.id},"releaseDateUtc":"2026-07-25T00:00:00Z","seriesIds":[${series.id}],"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"} {"title":"created audio","detail":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"releaseDate":"2027-07-30T10:00:00Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"}
""".trimIndent().toByteArray() """.trimIndent().toByteArray()
) )
) )
@@ -127,28 +129,13 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.title").value("created audio")) .andExpect(jsonPath("$.data.contentId").isNumber)
.andExpect(jsonPath("$.data.description").value("created detail")) .andExpect(jsonPath("$.data.title").doesNotExist())
.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 @Test
@DisplayName("수정은 seriesIds를 target 소유 시리즈로 교체하고 상세를 반환한다") @DisplayName("수정은 text/plain request part를 415와 부작용 없음으로 거부한다")
fun shouldReplaceOwnedContentSeriesIds() { fun shouldRejectTextPlainRequestPartBeforeSideEffects() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-series-update-character", characterUUID = "v2-audio-series-update-character",
name = "v2-audio-series-update-character", name = "v2-audio-series-update-character",
@@ -156,7 +143,6 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
systemPrompt = "prompt" systemPrompt = "prompt"
) )
val oldSeries = saveSeries("old-series", character.creatorMember!!) val oldSeries = saveSeries("old-series", character.creatorMember!!)
val newSeries = saveSeries("new-series", character.creatorMember!!)
val content = saveAudioContent( val content = saveAudioContent(
owner = character.creatorMember!!, owner = character.creatorMember!!,
title = "series update title", title = "series update title",
@@ -164,6 +150,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
) )
saveSeriesContent(oldSeries, content) saveSeriesContent(oldSeries, content)
entityManager.flush() entityManager.flush()
val beforeAudioContents = countAudioContents()
val beforeSeriesContents = countSeriesContents()
mockMvc.perform( mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
@@ -172,15 +160,19 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.TEXT_PLAIN_VALUE,
"""{"seriesIds":[${newSeries.id}]}""".toByteArray() """{"title":"series update title","detail":"updated detail"}""".toByteArray()
) )
) )
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data.seriesIds.length()").value(1)) .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("$.data.seriesIds[0]").value(newSeries.id))
assertNoSideEffects(beforeAudioContents, beforeSeriesContents) {
assertEquals("series update title", entityManager.find(AudioContent::class.java, content.id).title)
assertEquals(oldSeries.id, linkedSeriesId(content.id!!))
}
} }
@ParameterizedTest @ParameterizedTest
@@ -189,8 +181,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
"en,Invalid request.", "en,Invalid request.",
"ja,無効なリクエストです。" "ja,無効なリクエストです。"
) )
@DisplayName("생성은 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다") @DisplayName("생성은 계약에 없는 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다")
fun shouldRejectCreateWithOtherCharacterSeriesIdsBeforeUpload(language: String, message: String) { fun shouldRejectCreateWithUnsupportedSeriesIdsBeforeUpload(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-series-target", characterUUID = "v2-audio-create-series-target",
name = "v2-audio-create-series-target", name = "v2-audio-create-series-target",
@@ -213,14 +205,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE,
""" """
{"title":"invalid series audio","description":"created detail","price":100,"isAdult":false,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]} {"title":"invalid series audio","detail":"created detail","tags":"","price":100,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]}
""".trimIndent().toByteArray() """.trimIndent().toByteArray()
) )
) )
@@ -233,8 +225,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("생성은 처리 완료 전 유지할 수 없는 isActive false를 거부한다") @DisplayName("생성은 레거시 계약에 없는 isActive 필드를 거부한다")
fun shouldRejectCreateWithInactiveRequest() { fun shouldRejectCreateWithUnsupportedIsActiveField() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-inactive-character", characterUUID = "v2-audio-create-inactive-character",
name = "v2-audio-create-inactive-character", name = "v2-audio-create-inactive-character",
@@ -248,14 +240,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE,
""" """
{"title":"inactive audio","description":"created detail","price":100,"isAdult":false,"isActive":false,"themeId":${theme.id},"seriesIds":[]} {"title":"inactive audio","detail":"created detail","tags":"","price":100,"isActive":false,"themeId":${theme.id}}
""".trimIndent().toByteArray() """.trimIndent().toByteArray()
) )
) )
@@ -283,14 +275,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE,
""" """
{"title":"invalid price audio","description":"created detail","price":1,"isAdult":false,"themeId":${theme.id},"seriesIds":[]} {"title":"invalid price audio","detail":"created detail","tags":"","price":1,"themeId":${theme.id}}
""".trimIndent().toByteArray() """.trimIndent().toByteArray()
) )
) )
@@ -323,7 +315,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"isActive":false,"releaseDateUtc":"2026-07-25T00:00:00Z"}""".toByteArray() """{"isActive":false,"releaseDateUtc":"2026-07-25T00:00:00Z"}""".toByteArray()
) )
) )
@@ -336,8 +328,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("AI 캐릭터 콘텐츠 목록은 target 소유 공개 콘텐츠만 signed URL과 보정된 페이지로 반환한다") @DisplayName("AI 캐릭터 콘텐츠 목록은 search_word와 레거시 signed URL item을 반환한다")
fun shouldListTargetContentsWithSignedUrlAndNormalizedPagination() { fun shouldListTargetContentsWithLegacySearchAndSignedUrl() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-list-character", characterUUID = "v2-audio-list-character",
name = "v2-audio-list-character", name = "v2-audio-list-character",
@@ -372,25 +364,22 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents") get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("search", "night") .param("search_word", "target night")
.param("status", "OPEN") .param("page", "0")
.param("page", "-1")
.param("size", "100") .param("size", "100")
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.totalCount").value(1)) .andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].contentId").value(content.id)) .andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id))
.andExpect(jsonPath("$.data.items[0].title").value("target night walk")) .andExpect(jsonPath("$.data.items[0].title").value("target night walk"))
.andExpect( .andExpect(
jsonPath("$.data.items[0].audioSignedUrl").value( jsonPath("$.data.items[0].contentUrl").value(
"https://signed.example.com/private/target-night-walk.mp3?Expires=1" "https://signed.example.com/private/target-night-walk.mp3?Expires=1"
) )
) )
.andExpect(jsonPath("$.data.items[0].content").doesNotExist()) .andExpect(jsonPath("$.data.page").doesNotExist())
Mockito.verify(audioContentCloudFront).generateSignedURL( Mockito.verify(audioContentCloudFront).generateSignedURL(
"private/target-night-walk.mp3", "private/target-night-walk.mp3",
@@ -431,8 +420,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("status SCHEDULED 목록은 target 소유 예약 콘텐츠만 반환한다") @DisplayName("search_word 목록은 target 소유 예약 콘텐츠도 검색한다")
fun shouldListScheduledContentsWhenStatusIsScheduled() { fun shouldSearchScheduledOwnedContents() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-list-scheduled-status-character", characterUUID = "v2-audio-list-scheduled-status-character",
name = "v2-audio-list-scheduled-status-character", name = "v2-audio-list-scheduled-status-character",
@@ -456,18 +445,17 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents") get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("status", "SCHEDULED") .param("search_word", "scheduled explicit")
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1)) .andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].contentId").value(scheduled.id)) .andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id))
.andExpect(jsonPath("$.data.items[0].status").value("SCHEDULED"))
} }
@Test @Test
@DisplayName("status SCHEDULED 목록은 UTC 기준 미래 예약 콘텐츠를 서버 시간대와 무관하게 반환한다") @DisplayName("목록은 UTC 미래 예약 콘텐츠의 레거시 releaseDate를 반환한다")
fun shouldListUtcFutureScheduledContentsWhenServerTimezoneIsAheadOfUtc() { fun shouldReturnLegacyReleaseDateForUtcFutureScheduledContent() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-list-utc-scheduled-character", characterUUID = "v2-audio-list-utc-scheduled-character",
name = "v2-audio-list-utc-scheduled-character", name = "v2-audio-list-utc-scheduled-character",
@@ -486,13 +474,13 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents") get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("status", "SCHEDULED") .param("search_word", "utc future")
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1)) .andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].contentId").value(scheduled.id)) .andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id))
.andExpect(jsonPath("$.data.items[0].status").value("SCHEDULED")) .andExpect(jsonPath("$.data.items[0].releaseDate").isString)
} }
@Test @Test
@@ -520,13 +508,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.contentId").value(content.id)) .andExpect(jsonPath("$.data.contentId").value(content.id))
.andExpect(jsonPath("$.data.description").value("detail"))
.andExpect(jsonPath("$.data.detail").value("detail")) .andExpect(jsonPath("$.data.detail").value("detail"))
.andExpect(jsonPath("$.data.languageCode").value("ko")) .andExpect(jsonPath("$.data.languageCode").value("ko"))
.andExpect(jsonPath("$.data.themeStr").value("theme-detail night walk")) .andExpect(jsonPath("$.data.themeStr").value("theme-detail night walk"))
.andExpect(jsonPath("$.data.tag").value("")) .andExpect(jsonPath("$.data.tag").value(""))
.andExpect(jsonPath("$.data.duration").value("01:10:00")) .andExpect(jsonPath("$.data.duration").value("01:10:00"))
.andExpect(jsonPath("$.data.releaseDate").doesNotExist()) .andExpect(jsonPath("$.data.releaseDate").value(nullValue()))
.andExpect(jsonPath("$.data.totalContentCount").doesNotExist()) .andExpect(jsonPath("$.data.totalContentCount").doesNotExist())
.andExpect(jsonPath("$.data.remainingContentCount").doesNotExist()) .andExpect(jsonPath("$.data.remainingContentCount").doesNotExist())
.andExpect(jsonPath("$.data.orderSequence").doesNotExist()) .andExpect(jsonPath("$.data.orderSequence").doesNotExist())
@@ -539,6 +526,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.remainingTime").doesNotExist()) .andExpect(jsonPath("$.data.remainingTime").doesNotExist())
.andExpect(jsonPath("$.data.creatorOtherContentList").isEmpty) .andExpect(jsonPath("$.data.creatorOtherContentList").isEmpty)
.andExpect(jsonPath("$.data.sameThemeOtherContentList").isEmpty) .andExpect(jsonPath("$.data.sameThemeOtherContentList").isEmpty)
.andExpect(jsonPath("$.data.isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.isLike").value(false)) .andExpect(jsonPath("$.data.isLike").value(false))
.andExpect(jsonPath("$.data.likeCount").value(0)) .andExpect(jsonPath("$.data.likeCount").value(0))
.andExpect(jsonPath("$.data.commentList").isEmpty) .andExpect(jsonPath("$.data.commentList").isEmpty)
@@ -551,17 +539,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.buyerList").isEmpty) .andExpect(jsonPath("$.data.buyerList").isEmpty)
.andExpect(jsonPath("$.data.isAvailableUsePoint").value(true)) .andExpect(jsonPath("$.data.isAvailableUsePoint").value(true))
.andExpect(jsonPath("$.data.translated").doesNotExist()) .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( .andExpect(
jsonPath("$.data.contentUrl").value( jsonPath("$.data.contentUrl").value(
"https://signed.example.com/private/detail-night-walk.mp3?Expires=1" "https://signed.example.com/private/detail-night-walk.mp3?Expires=1"
) )
) )
.andExpect(jsonPath("$.data.audioSignedUrl").doesNotExist())
.andExpect(jsonPath("$.data.content").doesNotExist()) .andExpect(jsonPath("$.data.content").doesNotExist())
Mockito.verify(audioContentCloudFront).generateSignedURL( Mockito.verify(audioContentCloudFront).generateSignedURL(
@@ -646,7 +629,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"title":"other after title"}""".toByteArray() """{"title":"other after title"}""".toByteArray()
) )
) )
@@ -699,7 +682,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"seriesIds":[${otherSeries.id}]}""".toByteArray() """{"seriesIds":[${otherSeries.id}]}""".toByteArray()
) )
) )
@@ -714,19 +697,20 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
} }
@Test @ParameterizedTest
@DisplayName("수정은 audioFile 교체를 거부한다") @ValueSource(strings = ["audioFile", "contentFile"])
fun shouldRejectAudioFileUpdate() { @DisplayName("수정은 audioFile과 contentFile 교체를 거부한다")
fun shouldRejectUnsupportedContentFileUpdate(filePart: String) {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-audio-file-character", characterUUID = "v2-audio-update-$filePart-character",
name = "v2-audio-update-audio-file-character", name = "v2-audio-update-$filePart-character",
description = "description", description = "description",
systemPrompt = "prompt" systemPrompt = "prompt"
) )
val content = saveAudioContent( val content = saveAudioContent(
owner = character.creatorMember!!, owner = character.creatorMember!!,
title = "audio file update title", title = "$filePart update title",
contentPath = "private/audio-file-update.mp3" contentPath = "private/$filePart-update.mp3"
) )
entityManager.flush() entityManager.flush()
val beforeAudioContents = countAudioContents() val beforeAudioContents = countAudioContents()
@@ -734,12 +718,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile(filePart, "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"{}".toByteArray() "{}".toByteArray()
) )
) )
@@ -749,7 +733,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.success").value(false))
assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { assertNoSideEffects(beforeAudioContents, beforeSeriesContents) {
assertEquals("audio file update title", entityManager.find(AudioContent::class.java, content.id).title) assertEquals("$filePart update title", entityManager.find(AudioContent::class.java, content.id).title)
} }
} }
@@ -784,7 +768,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"releaseDateUtc":"not-a-date"}""".toByteArray() """{"releaseDateUtc":"not-a-date"}""".toByteArray()
) )
) )
@@ -801,8 +785,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("수정은 활성 콘텐츠를 미래 예약으로 변경할 때 비활성화해 조기 공개를 막는") @DisplayName("수정은 레거시 계약에 없는 releaseDateUtc를 거부한")
fun shouldDeactivateContentWhenUpdatingFutureReleaseDateUtc() { fun shouldRejectUnsupportedFutureReleaseDateUtc() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-future-release-character", characterUUID = "v2-audio-update-future-release-character",
name = "v2-audio-update-future-release-character", name = "v2-audio-update-future-release-character",
@@ -825,22 +809,21 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"releaseDateUtc":"$releaseDateUtc"}""".toByteArray() """{"releaseDateUtc":"$releaseDateUtc"}""".toByteArray()
) )
) )
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isBadRequest)
.andExpect(jsonPath("$.data.isActive").value(false)) .andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data.status").value("SCHEDULED"))
assertEquals(false, entityManager.find(AudioContent::class.java, content.id).isActive) assertEquals(true, entityManager.find(AudioContent::class.java, content.id).isActive)
} }
@Test @Test
@DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 상세를 반환한다") @DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 data null을 반환한다")
fun shouldSoftDeleteOwnedContentAndReturnDetail() { fun shouldSoftDeleteOwnedContentAndReturnNull() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-character", characterUUID = "v2-audio-update-character",
name = "v2-audio-update-character", name = "v2-audio-update-character",
@@ -860,7 +843,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"title":"after title","isActive":false}""".toByteArray() """{"title":"after title","isActive":false}""".toByteArray()
) )
) )
@@ -868,9 +851,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.title").value("after title")) .andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.data.isActive").value(false))
.andExpect(jsonPath("$.data.releaseDateUtc").doesNotExist()) val updated = entityManager.find(AudioContent::class.java, content.id)
assertEquals("after title", updated.title)
assertEquals(false, updated.isActive)
assertEquals(null, updated.releaseDate)
} }
@Test @Test
@@ -895,8 +881,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.page").doesNotExist())
.andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.size").doesNotExist())
mockMvc.perform( mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents") get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
@@ -904,7 +890,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.size").doesNotExist())
} }
private fun saveAudioContent( private fun saveAudioContent(
@@ -1013,6 +999,15 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.toLong() .toLong()
} }
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
private fun adminAuthentication() = authentication( private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken(
MemberAdapter( MemberAdapter(

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.amazonaws.services.s3.AmazonS3Client import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.PutObjectRequest import com.amazonaws.services.s3.model.PutObjectRequest
import com.amazonaws.services.s3.model.PutObjectResult import com.amazonaws.services.s3.model.PutObjectResult
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.AudioContentService
@@ -15,11 +16,14 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame 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.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -29,6 +33,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import org.springframework.mock.web.MockPart
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
@@ -38,6 +43,7 @@ import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.RequestBuilder import org.springframework.test.web.servlet.RequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Propagation
@@ -55,6 +61,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
private val mockMvc: MockMvc, private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService, private val chatCharacterService: ChatCharacterService,
private val audioContentService: AudioContentService, private val audioContentService: AudioContentService,
private val objectMapper: ObjectMapper,
private val transactionTemplate: TransactionTemplate, private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@@ -94,7 +101,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertMissingPartRequest( assertMissingPartRequest(
requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!)) .file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, language) .header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication()), .with(adminAuthentication()),
@@ -107,9 +114,9 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
"coverImage,ko,잘못된 요청입니다.", "coverImage,ko,잘못된 요청입니다.",
"coverImage,en,Invalid request.", "coverImage,en,Invalid request.",
"coverImage,ja,無効なリクエストです。", "coverImage,ja,無効なリクエストです。",
"audioFile,ko,잘못된 요청입니다.", "contentFile,ko,잘못된 요청입니다.",
"audioFile,en,Invalid request.", "contentFile,en,Invalid request.",
"audioFile,ja,無効なリクエストです。" "contentFile,ja,無効なリクエストです。"
) )
@DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다") @DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다")
fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) { fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) {
@@ -121,12 +128,12 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
) )
val theme = saveTheme() val theme = saveTheme()
val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1) val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1)
val audioBytes = if (filePart == "audioFile") byteArrayOf() else byteArrayOf(1) val contentBytes = if (filePart == "contentFile") byteArrayOf() else byteArrayOf(1)
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", coverBytes)) .file(MockMultipartFile("coverImage", "cover.png", "image/png", coverBytes))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", audioBytes)) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", contentBytes))
.file(requestPart(theme.id!!)) .file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, language) .header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication()) .with(adminAuthentication())
@@ -147,8 +154,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
"en,Invalid request.", "en,Invalid request.",
"ja,無効なリクエストです。" "ja,無効なリクエストです。"
) )
@DisplayName("생성은 audioFile part 누락을 업로드 전 invalid request로 거부한다") @DisplayName("생성은 contentFile part 누락을 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingAudioFileBeforeUpload(language: String, message: String) { fun shouldRejectMissingContentFileBeforeUpload(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-missing-audio-character", characterUUID = "v2-audio-create-missing-audio-character",
name = "v2-audio-create-missing-audio-character", name = "v2-audio-create-missing-audio-character",
@@ -185,13 +192,132 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertMissingPartRequest( assertMissingPartRequest(
requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.header(HttpHeaders.ACCEPT_LANGUAGE, language) .header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication()), .with(adminAuthentication()),
message = message message = message
) )
} }
@ParameterizedTest
@CsvSource(
value = [
"text/plain,ko,잘못된 요청입니다.",
"text/plain,en,Invalid request.",
"text/plain,ja,無効なリクエストです。",
"<missing>,ko,잘못된 요청입니다.",
"<missing>,en,Invalid request.",
"<missing>,ja,無効なリクエストです。"
],
nullValues = ["<missing>"]
)
@DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
requestContentType: String?,
language: String,
message: String
) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language",
name = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!, requestContentType))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("생성은 미정의 multipart part를 업로드 전 invalid request로 거부한다")
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-undefined-part-$language",
name = "v2-audio-create-undefined-part-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("생성은 filename 없는 미정의 multipart part를 업로드 전 invalid request로 거부한다")
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-filename-less-part-$language",
name = "v2-audio-create-filename-less-part-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.part(MockPart("unexpected", "unexpected".toByteArray()))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@Test @Test
@DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다") @DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingThemeBeforeUpload() { fun shouldRejectMissingThemeBeforeUpload() {
@@ -205,7 +331,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(999999L)) .file(requestPart(999999L))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en") .header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication()) .with(adminAuthentication())
@@ -219,6 +345,166 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertEquals(0L, countAudioContents()) assertEquals(0L, countAudioContents())
} }
@ParameterizedTest
@CsvSource(
value = [
"onlyStart|ko|미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.",
"onlyStart|en|You must enter both preview start and end times, or neither.",
"onlyStart|ja|プレビューの開始時間と終了時間は両方入力するか、両方入力しないでください。",
"malformed|ko|미리 듣기 시간 형식은 00:30:00 과 같아야 합니다",
"malformed|en|Preview time format must be like 00:30:00.",
"malformed|ja|プレビュー時間の形式は00:30:00のようにする必要があります。",
"tooShort|ko|미리 듣기의 최소 시간은 15초 입니다.",
"tooShort|en|The minimum preview time is 15 seconds.",
"tooShort|ja|プレビューの最小時間は15秒です。"
],
delimiter = '|'
)
@DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다")
fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-preview-$previewCase-$language",
name = "v2-audio-create-preview-$previewCase-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
val previewFields = when (previewCase) {
"onlyStart" -> ",\"previewStartTime\":\"00:00:01\""
"malformed" -> ",\"previewStartTime\":\"00:1:01\",\"previewEndTime\":\"00:00:20\""
else -> ",\"previewStartTime\":\"00:00:01\",\"previewEndTime\":\"00:00:10\""
}
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id}$previewFields}
""".trimIndent().toByteArray()
)
)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@Test
@DisplayName("생성은 timezone request field를 업로드 전 invalid request로 거부한다")
fun shouldRejectTimezoneRequestFieldBeforeUpload() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-timezone-field-character",
name = "v2-audio-create-timezone-field-character",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"2027-07-30 10:00","timezone":"UTC"}
"""
)
}
@ParameterizedTest
@ValueSource(strings = ["2027-07-30 10:00", "2027-07-30T10:00:00+09:00", "2027-07-30T10:00:00+00:00"])
@DisplayName("생성은 local 또는 offset releaseDate를 업로드 전 invalid request로 거부한다")
fun shouldRejectNonUtcReleaseDateBeforeUpload(releaseDate: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}",
name = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"$releaseDate"}
"""
)
}
@ParameterizedTest
@ValueSource(strings = ["missing", "null"])
@DisplayName("생성은 price 누락과 null을 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingOrNullPriceBeforeUpload(priceCase: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-$priceCase-price-character",
name = "v2-audio-create-$priceCase-price-character",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
val priceJson = if (priceCase == "null") ",\"price\":null" else ""
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"","themeId":${theme.id}$priceJson}
"""
)
}
@ParameterizedTest
@ValueSource(
strings = [
"themeId",
"isAdult",
"isGeneratePreview",
"isOnlyRental",
"isPointAvailable",
"isCommentAvailable",
"isFullDetailVisible"
]
)
@DisplayName("생성은 non-null primitive null을 업로드 전 invalid request로 거부한다")
fun shouldRejectNullPrimitiveFieldsBeforeUpload(field: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-null-$field-character",
name = "v2-audio-create-null-$field-character",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
val primitiveFields = listOf(
"price" to "100",
"themeId" to theme.id.toString(),
"isAdult" to "false",
"isGeneratePreview" to "false",
"isOnlyRental" to "false",
"isPointAvailable" to "false",
"isCommentAvailable" to "false",
"isFullDetailVisible" to "true"
).joinToString(",") { (key, value) ->
val fieldValue = if (key == field) "null" else value
"\"$key\":$fieldValue"
}
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"",$primitiveFields}
"""
)
}
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED) @Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다") @DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다")
@@ -237,7 +523,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!)) .file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en") .header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication()) .with(adminAuthentication())
@@ -271,7 +557,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!)) .file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en") .header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication()) .with(adminAuthentication())
@@ -307,7 +593,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!)) .file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en") .header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication()) .with(adminAuthentication())
@@ -325,8 +611,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
} }
@Test @Test
@DisplayName("생성은 tags 누락과 isActive true를 legacy pipeline 기본 계약으로 허용한다") @DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다")
fun shouldAllowMissingTagsAndActiveTrueRequest() { fun shouldCreateWithUtcReleaseDateAndReturnContentId() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-minimal-character", characterUUID = "v2-audio-create-minimal-character",
name = "v2-audio-create-minimal-character", name = "v2-audio-create-minimal-character",
@@ -337,26 +623,74 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded")) .thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform( val response = mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE,
""" """
{"title":"minimal audio","description":"detail","price":100,"themeId":${theme.id},"isActive":true} {"title":"utc audio","detail":"utc detail","tags":"#utc","price":100,"purchaseOption":"BOTH","limited":7,"releaseDate":"2027-07-30T10:00:01Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":false,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"ko"}
""".trimIndent().toByteArray() """.trimIndent().toByteArray()
) )
) )
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.title").value("minimal audio")) .andExpect(jsonPath("$.data.contentId").isNumber)
.andExpect(jsonPath("$.data.tag").value("")) .andReturn()
.andExpect(jsonPath("$.data.isActive").value(false))
val data = objectMapper.readTree(response.response.contentAsString).path("data")
assertEquals(setOf("contentId"), data.fieldNames().asSequence().toSet())
val content = findAudioContentByTitle("utc audio")!!
assertEquals(data.path("contentId").asLong(), content.id)
assertEquals("utc detail", content.detail)
assertEquals(7, content.limited)
assertEquals("2027-07-30T10:00:01", content.releaseDate.toString())
val uploadCaptor = ArgumentCaptor.forClass(PutObjectRequest::class.java)
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(uploadCaptor.capture())
val audioUpload = uploadCaptor.allValues.single { it.key.startsWith("input/") }
assertEquals("00:00:05", audioUpload.metadata.userMetadata["preview_start_time"])
assertEquals("00:00:25", audioUpload.metadata.userMetadata["preview_end_time"])
assertTrue(audioUpload.metadata.userMetadata["generate_preview"].toBoolean())
}
@ParameterizedTest
@ValueSource(strings = ["", ",\"releaseDate\":null"])
@DisplayName("생성은 생략 또는 null releaseDate를 허용한다")
fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}",
name = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"nullable release","detail":"detail","tags":"","price":100,"themeId":${theme.id}$releaseDateField}
""".trimIndent().toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.contentId").isNumber)
} }
private fun saveTheme(): AudioContentTheme { private fun saveTheme(): AudioContentTheme {
@@ -366,13 +700,16 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
return theme return theme
} }
private fun requestPart(themeId: Long): MockMultipartFile { private fun requestPart(
themeId: Long,
contentType: String? = MediaType.APPLICATION_JSON_VALUE
): MockMultipartFile {
return MockMultipartFile( return MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.APPLICATION_JSON_VALUE, contentType,
""" """
{"title":"audio","description":"detail","price":100,"themeId":$themeId,"isActive":true} {"title":"audio","detail":"detail","tags":"","price":100,"themeId":$themeId}
""".trimIndent().toByteArray() """.trimIndent().toByteArray()
) )
} }
@@ -394,6 +731,30 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertEquals(0L, countAudioContents()) assertEquals(0L, countAudioContents())
} }
private fun assertInvalidCreateRequestBeforeSideEffects(characterId: Long, request: String) {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/$characterId/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
request.trimIndent().toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
private fun countAudioContents(): Long { private fun countAudioContents(): Long {
return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java)
.singleResult .singleResult

View File

@@ -133,7 +133,7 @@ class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor(
val result = mockMvc.perform( val result = mockMvc.perform(
multipart("/api/v2/admin/ai-characters/999999/audio-contents") multipart("/api/v2/admin/ai-characters/999999/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
@@ -205,7 +205,7 @@ class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor(
val result = mockMvc.perform( val result = mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/999999/audio-contents/1") multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/999999/audio-contents/1")
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.header(HttpHeaders.ACCEPT_LANGUAGE, language) .header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication()) .with(adminAuthentication())
) )

View File

@@ -1,15 +1,18 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse
import kr.co.vividnext.sodalive.content.PurchaseOption import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
@@ -36,14 +39,85 @@ import javax.persistence.EntityManager
class AiCharacterAdminAudioContentQueryTest @Autowired constructor( class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
private val mockMvc: MockMvc, private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService, private val chatCharacterService: ChatCharacterService,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@MockBean @MockBean
private lateinit var audioContentCloudFront: AudioContentCloudFront private lateinit var audioContentCloudFront: AudioContentCloudFront
@Test @Test
@DisplayName("상세는 과거 공개일을 releaseDateUtc에만 반환하고 legacy releaseDate는 비운") @DisplayName("목록은 search_word로 검색하고 레거시 전체 item exact 필드를 반환한")
fun shouldExposePastReleaseDateOnlyAsUtcField() { fun shouldSearchByLegacyQueryAndReturnFullLegacyListItem() {
registerMysqlDateFunctions()
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-list-character",
name = "v2-audio-query-list-character",
description = "description",
systemPrompt = "prompt"
)
val matching = saveAudioContent(
owner = character.creatorMember!!,
title = "legacy target audio",
releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0)
)
saveAudioContent(
owner = character.creatorMember!!,
title = "different audio",
releaseDate = LocalDateTime.of(2026, 7, 24, 0, 0)
)
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" }
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("search_word", "legacy target")
.param("page", "0")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
val item = data.path("items").path(0)
assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet())
assertEquals(
setOf(
"audioContentId",
"title",
"detail",
"coverImageUrl",
"creatorNickname",
"theme",
"price",
"totalContentCount",
"remainingContentCount",
"isAdult",
"isPointAvailable",
"isCommentAvailable",
"remainingTime",
"contentUrl",
"date",
"releaseDate",
"tags"
),
item.fieldNames().asSequence().toSet()
)
assertEquals(matching.id, item.path("audioContentId").asLong())
assertEquals("legacy target audio", item.path("title").asText())
assertEquals("detail", item.path("detail").asText())
assertEquals("https://test.cloudfront.net/cover/legacy target audio.png", item.path("coverImageUrl").asText())
assertEquals(
"https://signed.example.com/private/legacy target audio.mp3?Expires=1",
item.path("contentUrl").asText()
)
}
@Test
@DisplayName("상세는 timezone 없이 기존 nested DTO exact 필드를 반환한다")
fun shouldReturnFullLegacyDetailContractWithoutTimezone() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-release-character", characterUUID = "v2-audio-query-release-character",
name = "v2-audio-query-release-character", name = "v2-audio-query-release-character",
@@ -59,42 +133,150 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://signed.example.com/query-release.mp3?Expires=1") .thenReturn("https://signed.example.com/query-release.mp3?Expires=1")
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/query-release.mp3?Expires=1"))
.andExpect(jsonPath("$.data.isOnlyRental").value(true))
.andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY"))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
assertEquals(
setOf(
"contentId",
"title",
"detail",
"languageCode",
"coverImageUrl",
"contentUrl",
"themeStr",
"tag",
"price",
"duration",
"releaseDate",
"totalContentCount",
"remainingContentCount",
"orderSequence",
"isActivePreview",
"isAdult",
"isMosaic",
"isOnlyRental",
"existOrdered",
"purchaseOption",
"orderType",
"remainingTime",
"creatorOtherContentList",
"sameThemeOtherContentList",
"isCommentAvailable",
"isLike",
"likeCount",
"commentList",
"commentCount",
"isPin",
"isAvailablePin",
"creator",
"previousContent",
"nextContent",
"buyerList",
"isAvailableUsePoint",
"translated"
),
data.fieldNames().asSequence().toSet()
)
assertEquals(
setOf("creatorId", "nickname", "profileImageUrl", "isFollowing", "isFollow", "isNotify"),
data.path("creator").fieldNames().asSequence().toSet()
)
Mockito.verify(audioContentCloudFront).generateSignedURL("private/query release audio.mp3", 10_800_000L)
}
@Test
@DisplayName("상세는 미래 예약일을 UTC Z releaseDate로 반환한다")
fun shouldReturnFutureReleaseDateAsUtcIso() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-future-release",
name = "v2-audio-query-future-release",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "future release audio",
releaseDate = LocalDateTime.of(2027, 7, 30, 10, 0)
)
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://signed.example.com/future-release.mp3?Expires=1")
mockMvc.perform( mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.releaseDateUtc").value("2026-07-25T00:00:00Z")) .andExpect(jsonPath("$.data.releaseDate").value("2027-07-30T10:00:00Z"))
.andExpect(jsonPath("$.data.releaseDate").doesNotExist()) .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/future-release.mp3?Expires=1"))
.andExpect(jsonPath("$.data.isOnlyRental").value(true))
.andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY"))
} }
@Test @Test
@DisplayName("상세 응답 DTO는 legacy/public 중첩 DTO 타입을 직접 노출하지 않는") @DisplayName("상세는 현재 또는 과거 예약일의 releaseDate를 null로 반환한")
fun shouldUseV2NestedResponseTypes() { fun shouldReturnNullReleaseDateForPastOrCurrentReleaseDate() {
val legacyPackages = setOf( val character = chatCharacterService.createChatCharacterWithDetails(
"kr.co.vividnext.sodalive.content", characterUUID = "v2-audio-query-past-release-character",
"kr.co.vividnext.sodalive.content.comment", name = "v2-audio-query-past-release-character",
"kr.co.vividnext.sodalive.content.translation" description = "description",
systemPrompt = "prompt"
) )
val content = saveAudioContent(
val nestedResponseFieldNames = setOf( owner = character.creatorMember!!,
"creatorOtherContentList", title = "past release audio",
"sameThemeOtherContentList", releaseDate = LocalDateTime.of(2020, 1, 1, 0, 0)
"commentList",
"creator",
"previousContent",
"nextContent",
"buyerList",
"translated"
) )
val leakedTypes = AiCharacterAdminAudioContentResponse::class.java.declaredFields entityManager.flush()
.filter { it.name in nestedResponseFieldNames } Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.map { it.type.packageName } .thenReturn("https://signed.example.com/past-release.mp3?Expires=1")
.filter { it in legacyPackages }
assertFalse(leakedTypes.isNotEmpty(), "legacy nested DTO packages leaked: $leakedTypes") val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/past-release.mp3?Expires=1"))
.andReturn()
assertTrue(objectMapper.readTree(response.response.contentAsString).path("data").path("releaseDate").isNull)
}
@Test
@DisplayName("상세는 timezone query를 허용하지 않는다")
fun shouldRejectTimezoneForDetail() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-timezone-character",
name = "v2-audio-query-timezone-character",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "timezone required audio",
releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0)
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.param("timezone", "UTC")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
}
@Test
@DisplayName("상세 응답은 레거시 GetAudioContentDetailResponse 타입을 재사용한다")
fun shouldReuseLegacyDetailResponseType() {
assertEquals(GetAudioContentDetailResponse::class.java, AiCharacterAdminAudioContentResponse::class.java)
} }
private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent { private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent {
@@ -104,6 +286,8 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
title = title, title = title,
detail = "detail", detail = "detail",
languageCode = "ko", languageCode = "ko",
limited = 10,
remaining = 5,
price = 100, price = 100,
purchaseOption = PurchaseOption.RENT_ONLY, purchaseOption = PurchaseOption.RENT_ONLY,
isOnlyRental = false, isOnlyRental = false,
@@ -122,6 +306,15 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
} }
} }
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
private fun adminAuthentication() = authentication( private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken(
MemberAdapter( MemberAdapter(

View File

@@ -1,10 +1,12 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -28,11 +30,12 @@ import javax.persistence.EntityManager
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor( class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor(
private val mockMvc: MockMvc, private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@Test @Test
@DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 v2 필드명으로 반환한다") @DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 레거시 exact 필드로 반환한다")
fun shouldReturnActiveThemesWithV2FieldNames() { fun shouldReturnActiveThemesWithLegacyFields() {
val later = AudioContentTheme(theme = "later theme", image = "theme/later.png", orders = 2) 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 earlier = AudioContentTheme(theme = "earlier theme", image = "theme/earlier.png", orders = 1)
val inactive = AudioContentTheme(theme = "inactive theme", image = "theme/inactive.png", isActive = false) val inactive = AudioContentTheme(theme = "inactive theme", image = "theme/inactive.png", isActive = false)
@@ -41,19 +44,20 @@ class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor(
entityManager.persist(inactive) entityManager.persist(inactive)
entityManager.flush() entityManager.flush()
mockMvc.perform( val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/audio-content-themes") get("/api/v2/admin/ai-characters/audio-content-themes")
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.length()").value(2)) .andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].themeId").value(earlier.id)) .andExpect(jsonPath("$.data[0].id").value(earlier.id))
.andExpect(jsonPath("$.data[0].themeName").value("earlier theme")) .andExpect(jsonPath("$.data[0].theme").value("earlier theme"))
.andExpect(jsonPath("$.data[0].imageUrl").value("https://test.cloudfront.net/theme/earlier.png")) .andExpect(jsonPath("$.data[0].image").value("https://test.cloudfront.net/theme/earlier.png"))
.andExpect(jsonPath("$.data[0].id").doesNotExist()) .andReturn()
.andExpect(jsonPath("$.data[0].theme").doesNotExist())
.andExpect(jsonPath("$.data[0].image").doesNotExist()) val item = objectMapper.readTree(response.response.contentAsString).path("data").path(0)
assertEquals(setOf("id", "theme", "image"), item.fieldNames().asSequence().toSet())
} }
@Test @Test

View File

@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertSame
@@ -21,15 +22,20 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import org.springframework.mock.web.MockPart
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
@@ -38,6 +44,7 @@ import org.springframework.test.util.AopTestUtils
import org.springframework.test.util.ReflectionTestUtils import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -77,9 +84,149 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher) replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher)
} }
@ParameterizedTest
@CsvSource(
value = [
"text/plain,ko,잘못된 요청입니다.",
"text/plain,en,Invalid request.",
"text/plain,ja,無効なリクエストです。",
"<missing>,ko,잘못된 요청입니다.",
"<missing>,en,Invalid request.",
"<missing>,ja,無効なリクエストです。"
],
nullValues = ["<missing>"]
)
@DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
requestContentType: String?,
language: String,
message: String
) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language",
name = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "before update")
entityManager.flush()
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(
MockMultipartFile(
"request",
"request.json",
requestContentType,
"{\"title\":\"after update\"}".toByteArray()
)
)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
entityManager.clear()
assertEquals("before update", entityManager.find(AudioContent::class.java, content.id).title)
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("수정은 미정의 multipart part를 mutation 전 invalid request로 거부한다")
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-undefined-part-$language",
name = "v2-audio-update-undefined-part-$language",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "undefined-part-before")
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://test.cloudfront.net/private/undefined-part-before.mp3")
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"should not change"}""".toByteArray()
)
)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
entityManager.clear()
assertEquals("undefined-part-before", entityManager.find(AudioContent::class.java, content.id).title)
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("수정은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다")
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-filename-less-part-$language",
name = "v2-audio-update-filename-less-part-$language",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "filename-less-part-before")
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://test.cloudfront.net/private/filename-less-part-before.mp3")
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"should not change"}""".toByteArray()
)
)
.part(MockPart("unexpected", "unexpected".toByteArray()))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
entityManager.clear()
assertEquals("filename-less-part-before", entityManager.find(AudioContent::class.java, content.id).title)
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@Test @Test
@DisplayName("수정은 동일 seriesIds의 기존 연결 metadata를 보존한다") @DisplayName("수정은 레거시 전체 request를 적용하고 기존 series metadata와 data null 계약을 보존한다")
fun shouldPreserveExistingSeriesContentMetadataForSameSeriesIds() { fun shouldApplyLegacyRequestAndPreserveExistingSeriesMetadata() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-preserve-series-character", characterUUID = "v2-audio-update-preserve-series-character",
name = "v2-audio-update-preserve-series-character", name = "v2-audio-update-preserve-series-character",
@@ -103,24 +250,43 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"seriesIds":[${series.id}]}""".toByteArray() """
{
"title":"updated title",
"detail":"updated detail",
"tags":"#updated",
"price":250,
"isAdult":true,
"isActive":false,
"isPointAvailable":false,
"isCommentAvailable":false
}
""".trimIndent().toByteArray()
) )
) )
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.seriesIds[0]").value(series.id)) .andExpect(jsonPath("$.data").value(nullValue()))
val saved = findSeriesContents(content.id!!).single() val saved = findSeriesContents(content.id!!).single()
assertEquals(beforeId, saved.id) assertEquals(beforeId, saved.id)
assertEquals(7, saved.orders) assertEquals(7, saved.orders)
assertEquals(beforeCreatedAt, saved.createdAt) assertEquals(beforeCreatedAt, saved.createdAt)
val updated = entityManager.find(AudioContent::class.java, content.id)
assertEquals("updated title", updated.title)
assertEquals("updated detail", updated.detail)
assertEquals(250, updated.price)
assertEquals(true, updated.isAdult)
assertEquals(false, updated.isActive)
assertEquals(false, updated.isPointAvailable)
assertEquals(false, updated.isCommentAvailable)
} }
@Test @Test
@DisplayName("수정은 seriesIds 교집합 metadata를 보존하고 차집합만 추가·제거") @DisplayName("레거시 수정은 기존 series 연결을 추가·제거하지 않는")
fun shouldOnlyInsertAndDeleteSeriesContentDifference() { fun shouldLeaveSeriesConnectionsUnchanged() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-diff-series-character", characterUUID = "v2-audio-update-diff-series-character",
name = "v2-audio-update-diff-series-character", name = "v2-audio-update-diff-series-character",
@@ -147,21 +313,22 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"seriesIds":[${keptSeries.id},${addedSeries.id}]}""".toByteArray() """{"title":"diff updated title"}""".toByteArray()
) )
) )
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.data.seriesIds.length()").value(2)) .andExpect(jsonPath("$.data").value(nullValue()))
val saved = findSeriesContents(content.id!!) val saved = findSeriesContents(content.id!!)
assertEquals(setOf(keptSeries.id, addedSeries.id), saved.map { it.series!!.id }.toSet()) assertEquals(setOf(removedSeries.id, keptSeries.id), saved.map { it.series!!.id }.toSet())
val kept = saved.single { it.series!!.id == keptSeries.id } val kept = saved.single { it.series!!.id == keptSeries.id }
assertEquals(keptId, kept.id) assertEquals(keptId, kept.id)
assertEquals(5, kept.orders) assertEquals(5, kept.orders)
assertEquals(keptCreatedAt, kept.createdAt) assertEquals(keptCreatedAt, kept.createdAt)
assertEquals(false, saved.any { it.series!!.id == addedSeries.id })
} }
@Test @Test
@@ -181,7 +348,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
@@ -193,7 +360,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") 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("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
@@ -221,7 +388,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
mockMvc.perform( mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("coverImage", "empty.png", "image/png", byteArrayOf())) .file(MockMultipartFile("coverImage", "empty.png", "image/png", byteArrayOf()))
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray())) .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.with(adminAuthentication()) .with(adminAuthentication())
) )
.andExpect(status().isOk) .andExpect(status().isOk)
@@ -232,27 +399,28 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
Mockito.verifyNoInteractions(applicationEventPublisher) Mockito.verifyNoInteractions(applicationEventPublisher)
} }
@Test @ParameterizedTest
@DisplayName("수정은 빈 audioFile part도 교체 요청으로 거부한다") @ValueSource(strings = ["audioFile", "contentFile"])
fun shouldRejectEmptyAudioFileUpdate() { @DisplayName("수정은 파일 교체 part를 mutation 전 invalid request로 거부한다")
fun shouldRejectFileReplacementPartBeforeSideEffects(filePart: String) {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-empty-audio-character", characterUUID = "v2-audio-update-file-replacement-$filePart",
name = "v2-audio-update-empty-audio-character", name = "v2-audio-update-file-replacement-$filePart",
description = "description", description = "description",
systemPrompt = "prompt" systemPrompt = "prompt"
) )
val content = saveAudioContent(character.creatorMember!!, "empty-audio-update-content") val content = saveAudioContent(character.creatorMember!!, "file-replacement-$filePart")
val oldTitle = content.title val oldTitle = content.title
entityManager.flush() entityManager.flush()
mockMvc.perform( mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("audioFile", "empty.mp3", "audio/mpeg", byteArrayOf())) .file(MockMultipartFile(filePart, "empty.mp3", "audio/mpeg", byteArrayOf()))
.file( .file(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"title":"should not change"}""".toByteArray() """{"title":"should not change"}""".toByteArray()
) )
) )
@@ -290,7 +458,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
MockMultipartFile( MockMultipartFile(
"request", "request",
"request.json", "request.json",
MediaType.TEXT_PLAIN_VALUE, MediaType.APPLICATION_JSON_VALUE,
"""{"title":"cover failure after title"}""".toByteArray() """{"title":"cover failure after title"}""".toByteArray()
) )
) )