test #443
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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?
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user