feat(ai-character): 오디오 콘텐츠 관리자 API를 추가한다
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/admin/ai-characters")
|
||||||
|
class AiCharacterAdminAudioContentController(
|
||||||
|
private val facade: AiCharacterAdminAudioContentFacade
|
||||||
|
) {
|
||||||
|
@GetMapping("/audio-content-themes")
|
||||||
|
fun themes(): ApiResponse<List<AiCharacterAdminAudioContentThemeResponse>> {
|
||||||
|
return ApiResponse.ok(facade.themes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{characterId:[0-9]+}/audio-contents")
|
||||||
|
fun list(
|
||||||
|
@PathVariable characterId: Long,
|
||||||
|
@RequestParam(required = false) search: String?,
|
||||||
|
@RequestParam(required = false) status: String?,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
): ApiResponse<AiCharacterAdminAudioContentListResponse> {
|
||||||
|
return ApiResponse.ok(facade.list(characterId, search, status, page, size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}")
|
||||||
|
fun detail(
|
||||||
|
@PathVariable characterId: Long,
|
||||||
|
@PathVariable contentId: Long
|
||||||
|
): ApiResponse<AiCharacterAdminAudioContentResponse> {
|
||||||
|
return ApiResponse.ok(facade.detail(characterId, contentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
|
fun create(
|
||||||
|
@PathVariable characterId: Long,
|
||||||
|
@RequestPart("coverImage") coverImage: MultipartFile,
|
||||||
|
@RequestPart("audioFile") audioFile: MultipartFile,
|
||||||
|
@RequestPart("request") request: String
|
||||||
|
): ApiResponse<AiCharacterAdminAudioContentResponse> {
|
||||||
|
return ApiResponse.ok(facade.create(characterId, coverImage, audioFile, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(
|
||||||
|
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}",
|
||||||
|
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]
|
||||||
|
)
|
||||||
|
fun update(
|
||||||
|
@PathVariable characterId: Long,
|
||||||
|
@PathVariable contentId: Long,
|
||||||
|
@RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?,
|
||||||
|
@RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?,
|
||||||
|
@RequestPart("request") request: String
|
||||||
|
): ApiResponse<AiCharacterAdminAudioContentResponse> {
|
||||||
|
return ApiResponse.ok(facade.update(characterId, contentId, coverImage, audioFile, request))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.PurchaseOption
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
|
||||||
|
data class AiCharacterAdminAudioContentListResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val items: List<AiCharacterAdminAudioContentListItem>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AiCharacterAdminAudioContentListItem(
|
||||||
|
val contentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String,
|
||||||
|
val audioSignedUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val releaseDateUtc: String?,
|
||||||
|
val status: AiCharacterAdminAudioContentStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AiCharacterAdminAudioContentResponse(
|
||||||
|
val contentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val detail: String,
|
||||||
|
val coverImageUrl: String,
|
||||||
|
val audioSignedUrl: String?,
|
||||||
|
val contentUrl: String?,
|
||||||
|
val languageCode: String?,
|
||||||
|
val themeStr: String,
|
||||||
|
val tag: String,
|
||||||
|
val price: Int,
|
||||||
|
val duration: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
val isCommentAvailable: Boolean,
|
||||||
|
val releaseDateUtc: String?,
|
||||||
|
val releaseDate: String?,
|
||||||
|
val totalContentCount: Int?,
|
||||||
|
val remainingContentCount: Int?,
|
||||||
|
val orderSequence: Int?,
|
||||||
|
val isActivePreview: Boolean,
|
||||||
|
val isMosaic: Boolean,
|
||||||
|
val isOnlyRental: Boolean,
|
||||||
|
val existOrdered: Boolean,
|
||||||
|
val purchaseOption: PurchaseOption,
|
||||||
|
val orderType: OrderType?,
|
||||||
|
val remainingTime: String?,
|
||||||
|
val creatorOtherContentList: List<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(
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val price: Int,
|
||||||
|
val isAdult: Boolean = false,
|
||||||
|
val isActive: Boolean = true,
|
||||||
|
val themeId: Long,
|
||||||
|
val tags: String = "",
|
||||||
|
val purchaseOption: PurchaseOption = PurchaseOption.BOTH,
|
||||||
|
val limited: Int? = null,
|
||||||
|
val releaseDateUtc: String? = null,
|
||||||
|
val isGeneratePreview: Boolean = false,
|
||||||
|
val isOnlyRental: Boolean = false,
|
||||||
|
val isPointAvailable: Boolean = false,
|
||||||
|
val isCommentAvailable: Boolean = false,
|
||||||
|
val isFullDetailVisible: Boolean = true,
|
||||||
|
val previewStartTime: String? = null,
|
||||||
|
val previewEndTime: String? = null,
|
||||||
|
val languageCode: String? = null,
|
||||||
|
val seriesIds: List<Long> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AiCharacterAdminAudioContentUpdateRequest(
|
||||||
|
val title: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val tags: String? = null,
|
||||||
|
val price: Int? = null,
|
||||||
|
val isAdult: Boolean? = null,
|
||||||
|
val isActive: Boolean? = null,
|
||||||
|
val releaseDateUtc: String? = null,
|
||||||
|
val seriesIds: List<Long>? = null,
|
||||||
|
val isPointAvailable: Boolean? = null,
|
||||||
|
val isCommentAvailable: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class AiCharacterAdminAudioContentStatus {
|
||||||
|
OPEN,
|
||||||
|
SCHEDULED
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
|
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AiCharacterAdminAudioContentFacade(
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val targetResolver: AiCharacterAdminTargetResolver,
|
||||||
|
private val repository: AiCharacterAdminAudioContentRepository,
|
||||||
|
private val themeQueryRepository: AudioContentThemeQueryRepository,
|
||||||
|
private val mapper: AiCharacterAdminAudioContentMapper,
|
||||||
|
private val audioContentService: AudioContentService,
|
||||||
|
private val creatorAdminContentService: CreatorAdminContentService
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun themes(): List<AiCharacterAdminAudioContentThemeResponse> {
|
||||||
|
return themeQueryRepository.getActiveThemes().map {
|
||||||
|
AiCharacterAdminAudioContentThemeResponse(
|
||||||
|
themeId = it.id,
|
||||||
|
themeName = it.theme,
|
||||||
|
imageUrl = it.image
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun list(
|
||||||
|
characterId: Long,
|
||||||
|
search: String?,
|
||||||
|
status: String?,
|
||||||
|
page: Int,
|
||||||
|
size: Int
|
||||||
|
): AiCharacterAdminAudioContentListResponse {
|
||||||
|
val target = targetResolver.resolve(characterId)
|
||||||
|
val normalizedSearch = search?.trim().orEmpty()
|
||||||
|
if (normalizedSearch.length == 1) throw invalidRequest()
|
||||||
|
|
||||||
|
val normalizedPage = page.coerceAtLeast(0)
|
||||||
|
val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE)
|
||||||
|
val contents = repository.findPage(
|
||||||
|
creatorMemberId = target.creatorMember.id ?: throw invalidRequest(),
|
||||||
|
search = normalizedSearch,
|
||||||
|
status = parseStatus(status),
|
||||||
|
pageable = PageRequest.of(normalizedPage, normalizedSize)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AiCharacterAdminAudioContentListResponse(
|
||||||
|
totalCount = contents.totalElements,
|
||||||
|
items = contents.content.map(mapper::toListItem),
|
||||||
|
page = normalizedPage,
|
||||||
|
size = normalizedSize,
|
||||||
|
hasNext = contents.hasNext()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun detail(characterId: Long, contentId: Long): AiCharacterAdminAudioContentResponse {
|
||||||
|
val target = targetResolver.resolve(characterId)
|
||||||
|
val content = repository.findByIdAndCreatorMemberId(
|
||||||
|
contentId = contentId,
|
||||||
|
creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||||
|
) ?: throw invalidRequest()
|
||||||
|
|
||||||
|
return mapper.toResponse(content, target.creatorMember.id ?: throw invalidRequest())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(
|
||||||
|
characterId: Long,
|
||||||
|
coverImage: MultipartFile,
|
||||||
|
audioFile: MultipartFile,
|
||||||
|
requestString: String
|
||||||
|
): AiCharacterAdminAudioContentResponse {
|
||||||
|
val target = targetResolver.resolve(characterId)
|
||||||
|
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||||
|
if (coverImage.isEmpty || audioFile.isEmpty) throw invalidRequest()
|
||||||
|
val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java)
|
||||||
|
if (!request.isActive) throw invalidRequest()
|
||||||
|
validateSeriesIds(request.seriesIds, creatorMemberId)
|
||||||
|
val created = audioContentService.createAudioContent(
|
||||||
|
contentFile = audioFile,
|
||||||
|
coverImage = coverImage,
|
||||||
|
requestString = objectMapper.writeValueAsString(
|
||||||
|
CreateAudioContentRequest(
|
||||||
|
title = request.title,
|
||||||
|
detail = request.description,
|
||||||
|
tags = request.tags,
|
||||||
|
price = request.price,
|
||||||
|
purchaseOption = request.purchaseOption,
|
||||||
|
limited = request.limited,
|
||||||
|
releaseDate = request.releaseDateUtc?.toLegacyReleaseDate(),
|
||||||
|
timezone = UTC_TIMEZONE,
|
||||||
|
themeId = request.themeId,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
isGeneratePreview = request.isGeneratePreview,
|
||||||
|
isOnlyRental = request.isOnlyRental,
|
||||||
|
isPointAvailable = request.isPointAvailable,
|
||||||
|
isCommentAvailable = request.isCommentAvailable,
|
||||||
|
isFullDetailVisible = request.isFullDetailVisible,
|
||||||
|
previewStartTime = request.previewStartTime,
|
||||||
|
previewEndTime = request.previewEndTime,
|
||||||
|
languageCode = request.languageCode
|
||||||
|
)
|
||||||
|
),
|
||||||
|
member = target.creatorMember
|
||||||
|
)
|
||||||
|
val content = repository.findByIdAndCreatorMemberId(created.contentId, creatorMemberId) ?: throw invalidRequest()
|
||||||
|
replaceSeriesIds(content, request.seriesIds, creatorMemberId)
|
||||||
|
|
||||||
|
return mapper.toResponse(content, creatorMemberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun update(
|
||||||
|
characterId: Long,
|
||||||
|
contentId: Long,
|
||||||
|
coverImage: MultipartFile?,
|
||||||
|
audioFile: MultipartFile?,
|
||||||
|
requestString: String
|
||||||
|
): AiCharacterAdminAudioContentResponse {
|
||||||
|
val target = targetResolver.resolve(characterId)
|
||||||
|
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||||
|
repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest()
|
||||||
|
if (audioFile != null) throw invalidRequest()
|
||||||
|
val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty }
|
||||||
|
|
||||||
|
val request = readRequest(requestString, AiCharacterAdminAudioContentUpdateRequest::class.java)
|
||||||
|
request.seriesIds?.let { validateSeriesIds(it, creatorMemberId) }
|
||||||
|
if (request.isActive == false && request.releaseDateUtc != null) throw invalidRequest()
|
||||||
|
val hasReleaseDateUtc = hasField(requestString, "releaseDateUtc")
|
||||||
|
val releaseDateUtc = if (hasReleaseDateUtc) request.releaseDateUtc?.toUtcLocalDateTime() else null
|
||||||
|
creatorAdminContentService.updateAudioContent(
|
||||||
|
coverImage = normalizedCoverImage,
|
||||||
|
requestString = objectMapper.writeValueAsString(
|
||||||
|
CreatorAdminAudioContentUpdatePayload(
|
||||||
|
id = contentId,
|
||||||
|
title = request.title,
|
||||||
|
detail = request.description,
|
||||||
|
tags = request.tags,
|
||||||
|
price = request.price,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
isActive = request.isActive,
|
||||||
|
isPointAvailable = request.isPointAvailable,
|
||||||
|
isCommentAvailable = request.isCommentAvailable
|
||||||
|
)
|
||||||
|
),
|
||||||
|
member = target.creatorMember
|
||||||
|
)
|
||||||
|
val updatedContent = repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest()
|
||||||
|
if (hasReleaseDateUtc) {
|
||||||
|
updatedContent.releaseDate = releaseDateUtc
|
||||||
|
if (releaseDateUtc?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) {
|
||||||
|
updatedContent.isActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.seriesIds?.let { replaceSeriesIds(updatedContent, it, creatorMemberId) }
|
||||||
|
|
||||||
|
return mapper.toResponse(updatedContent, creatorMemberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceSeriesIds(content: AudioContent, seriesIds: List<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 {
|
||||||
|
return try {
|
||||||
|
objectMapper.readValue(requestString, requestClass)
|
||||||
|
} catch (_: JsonProcessingException) {
|
||||||
|
throw invalidRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasField(requestString: String, fieldName: String): Boolean {
|
||||||
|
return try {
|
||||||
|
objectMapper.readTree(requestString).has(fieldName)
|
||||||
|
} catch (_: JsonProcessingException) {
|
||||||
|
throw invalidRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toLegacyReleaseDate(): String {
|
||||||
|
return toUtcLocalDateTime().format(LEGACY_RELEASE_DATE_FORMATTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toUtcLocalDateTime(): LocalDateTime {
|
||||||
|
return try {
|
||||||
|
Instant.parse(this).atOffset(ZoneOffset.UTC).toLocalDateTime()
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
throw invalidRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidRequest(): AiCharacterAdminApiException {
|
||||||
|
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MINIMUM_PAGE_SIZE = 20
|
||||||
|
private const val MAXIMUM_PAGE_SIZE = 50
|
||||||
|
private const val UTC_TIMEZONE = "UTC"
|
||||||
|
private val LEGACY_RELEASE_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CreatorAdminAudioContentUpdatePayload(
|
||||||
|
val id: Long,
|
||||||
|
val title: String?,
|
||||||
|
val detail: String?,
|
||||||
|
val tags: String?,
|
||||||
|
val price: Int?,
|
||||||
|
val isAdult: Boolean?,
|
||||||
|
val isActive: Boolean?,
|
||||||
|
val isPointAvailable: Boolean?,
|
||||||
|
val isCommentAvailable: Boolean?
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.PurchaseOption
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AiCharacterAdminAudioContentMapper(
|
||||||
|
private val repository: AiCharacterAdminAudioContentRepository,
|
||||||
|
private val audioContentCloudFront: AudioContentCloudFront,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
|
||||||
|
) {
|
||||||
|
fun toListItem(content: AudioContent): AiCharacterAdminAudioContentListItem {
|
||||||
|
return AiCharacterAdminAudioContentListItem(
|
||||||
|
contentId = content.id ?: throw invalidRequest(),
|
||||||
|
title = content.title,
|
||||||
|
coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}",
|
||||||
|
audioSignedUrl = signedUrl(content),
|
||||||
|
price = content.price,
|
||||||
|
isAdult = content.isAdult,
|
||||||
|
isActive = content.isActive,
|
||||||
|
releaseDateUtc = content.releaseDate?.toUtcIso(),
|
||||||
|
status = status(content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toResponse(content: AudioContent, creatorMemberId: Long): AiCharacterAdminAudioContentResponse {
|
||||||
|
val releaseDateUtc = content.releaseDate?.toUtcIso()
|
||||||
|
val owner = content.member ?: throw invalidRequest()
|
||||||
|
val audioSignedUrl = signedUrl(content)
|
||||||
|
return AiCharacterAdminAudioContentResponse(
|
||||||
|
contentId = content.id ?: throw invalidRequest(),
|
||||||
|
title = content.title,
|
||||||
|
description = content.detail,
|
||||||
|
detail = content.detail,
|
||||||
|
coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}",
|
||||||
|
audioSignedUrl = audioSignedUrl,
|
||||||
|
contentUrl = audioSignedUrl,
|
||||||
|
languageCode = content.languageCode,
|
||||||
|
themeStr = content.theme?.theme ?: "",
|
||||||
|
tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "),
|
||||||
|
price = content.price,
|
||||||
|
duration = content.duration ?: "",
|
||||||
|
isAdult = content.isAdult,
|
||||||
|
isActive = content.isActive,
|
||||||
|
isPointAvailable = content.isPointAvailable,
|
||||||
|
isCommentAvailable = content.isCommentAvailable,
|
||||||
|
releaseDateUtc = releaseDateUtc,
|
||||||
|
releaseDate = null,
|
||||||
|
totalContentCount = content.limited,
|
||||||
|
remainingContentCount = content.remaining,
|
||||||
|
orderSequence = null,
|
||||||
|
isActivePreview = content.isGeneratePreview,
|
||||||
|
isMosaic = false,
|
||||||
|
isOnlyRental = content.purchaseOption == PurchaseOption.RENT_ONLY || content.isOnlyRental,
|
||||||
|
existOrdered = false,
|
||||||
|
purchaseOption = if (content.isOnlyRental) PurchaseOption.RENT_ONLY else content.purchaseOption,
|
||||||
|
orderType = null,
|
||||||
|
remainingTime = null,
|
||||||
|
creatorOtherContentList = emptyList(),
|
||||||
|
sameThemeOtherContentList = emptyList(),
|
||||||
|
isLike = false,
|
||||||
|
likeCount = 0,
|
||||||
|
commentList = emptyList(),
|
||||||
|
commentCount = 0,
|
||||||
|
isPin = false,
|
||||||
|
isAvailablePin = false,
|
||||||
|
creator = AiCharacterAdminAudioContentCreatorResponse(
|
||||||
|
creatorId = owner.id ?: throw invalidRequest(),
|
||||||
|
nickname = owner.nickname,
|
||||||
|
profileImageUrl = if (owner.profileImage != null) {
|
||||||
|
"$imageHost/${owner.profileImage}"
|
||||||
|
} else {
|
||||||
|
"$imageHost/profile/default-profile.png"
|
||||||
|
},
|
||||||
|
isFollowing = false,
|
||||||
|
isFollow = false,
|
||||||
|
isNotify = false
|
||||||
|
),
|
||||||
|
previousContent = null,
|
||||||
|
nextContent = null,
|
||||||
|
buyerList = emptyList(),
|
||||||
|
isAvailableUsePoint = content.isPointAvailable,
|
||||||
|
translated = null,
|
||||||
|
status = status(content),
|
||||||
|
seriesIds = repository.findSeriesIds(content.id ?: throw invalidRequest(), creatorMemberId),
|
||||||
|
createdAtUtc = content.createdAt?.toUtcIso(),
|
||||||
|
updatedAtUtc = content.updatedAt?.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signedUrl(content: AudioContent): String? {
|
||||||
|
val resourcePath = content.content ?: return null
|
||||||
|
val duration = content.duration ?: return null
|
||||||
|
val expirationTime = 1000 * 60 * 60 * (duration.split(":")[0].toLong() + 2)
|
||||||
|
return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun status(content: AudioContent): AiCharacterAdminAudioContentStatus {
|
||||||
|
return if (content.releaseDate?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) {
|
||||||
|
AiCharacterAdminAudioContentStatus.SCHEDULED
|
||||||
|
} else {
|
||||||
|
AiCharacterAdminAudioContentStatus.OPEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDateTime.toUtcIso(): String {
|
||||||
|
return atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidRequest(): AiCharacterAdminApiException {
|
||||||
|
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.PageImpl
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AiCharacterAdminAudioContentRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
fun findPage(
|
||||||
|
creatorMemberId: Long,
|
||||||
|
search: String,
|
||||||
|
status: AiCharacterAdminAudioContentStatus?,
|
||||||
|
pageable: Pageable
|
||||||
|
): Page<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? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.where(audioContent.id.eq(contentId).and(member.id.eq(creatorMemberId)))
|
||||||
|
.fetchOne()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findSeriesIds(contentId: Long, creatorMemberId: Long): List<Long> {
|
||||||
|
return queryFactory
|
||||||
|
.select(series.id)
|
||||||
|
.from(seriesContent)
|
||||||
|
.innerJoin(seriesContent.series, series)
|
||||||
|
.innerJoin(seriesContent.content, audioContent)
|
||||||
|
.where(
|
||||||
|
audioContent.id.eq(contentId)
|
||||||
|
.and(audioContent.member.id.eq(creatorMemberId))
|
||||||
|
.and(series.member.id.eq(creatorMemberId))
|
||||||
|
.and(series.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceSeriesIds(content: AudioContent, seriesIds: List<Long>, creatorMemberId: Long) {
|
||||||
|
val requestedIds = seriesIds.distinct()
|
||||||
|
val existingSeriesContents = findSeriesContents(content.id ?: throw IllegalArgumentException(), creatorMemberId)
|
||||||
|
val existingSeriesIds = existingSeriesContents.mapNotNull { it.series?.id }.toSet()
|
||||||
|
existingSeriesContents
|
||||||
|
.filter { it.series?.id !in requestedIds }
|
||||||
|
.forEach(entityManager::remove)
|
||||||
|
|
||||||
|
if (requestedIds.isEmpty()) return
|
||||||
|
|
||||||
|
val seriesList = findActiveSeriesByIds(requestedIds, creatorMemberId)
|
||||||
|
if (seriesList.size != requestedIds.size) throw IllegalArgumentException()
|
||||||
|
|
||||||
|
seriesList.forEach {
|
||||||
|
if (it.id in existingSeriesIds) return@forEach
|
||||||
|
val seriesContent = SeriesContent()
|
||||||
|
seriesContent.series = it
|
||||||
|
seriesContent.content = content
|
||||||
|
entityManager.persist(seriesContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSeriesContents(contentId: Long, creatorMemberId: Long): List<SeriesContent> {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(seriesContent)
|
||||||
|
.innerJoin(seriesContent.series, series)
|
||||||
|
.where(
|
||||||
|
seriesContent.content.id.eq(contentId)
|
||||||
|
.and(series.member.id.eq(creatorMemberId))
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasActiveSeriesIds(seriesIds: List<Long>, creatorMemberId: Long): Boolean {
|
||||||
|
return seriesIds.isEmpty() || findActiveSeriesByIds(seriesIds, creatorMemberId).size == seriesIds.distinct().size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findActiveSeriesByIds(seriesIds: List<Long>, creatorMemberId: Long): List<Series> {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(series)
|
||||||
|
.where(
|
||||||
|
series.id.`in`(seriesIds)
|
||||||
|
.and(series.member.id.eq(creatorMemberId))
|
||||||
|
.and(series.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findWhere(
|
||||||
|
creatorMemberId: Long,
|
||||||
|
search: String,
|
||||||
|
status: AiCharacterAdminAudioContentStatus?
|
||||||
|
): BooleanExpression {
|
||||||
|
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
|
var where = audioContent.duration.isNotNull
|
||||||
|
.and(audioContent.member.id.eq(creatorMemberId))
|
||||||
|
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
|
||||||
|
|
||||||
|
if (status == AiCharacterAdminAudioContentStatus.SCHEDULED) {
|
||||||
|
where = where.and(audioContent.releaseDate.after(now))
|
||||||
|
} else if (status == AiCharacterAdminAudioContentStatus.OPEN) {
|
||||||
|
where = where.and(audioContent.releaseDate.isNull.or(audioContent.releaseDate.loe(now)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.length > 1) {
|
||||||
|
where = where.and(audioContent.title.contains(search).or(member.nickname.contains(search)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return where
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.core.Ordered
|
import org.springframework.core.Ordered
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
@@ -54,6 +55,7 @@ class AiCharacterAdminExceptionHandler(
|
|||||||
private fun resolveError(exception: Exception): ResolvedError {
|
private fun resolveError(exception: Exception): ResolvedError {
|
||||||
return when (exception) {
|
return when (exception) {
|
||||||
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
|
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
|
||||||
|
is SodaException -> ResolvedError(HttpStatus.BAD_REQUEST, exception.messageKey ?: "common.error.invalid_request")
|
||||||
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
|
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
|
||||||
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
|
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
|
||||||
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class H2MysqlDateFunctions {
|
|||||||
.replace("%Y", "yyyy")
|
.replace("%Y", "yyyy")
|
||||||
.replace("%m", "MM")
|
.replace("%m", "MM")
|
||||||
.replace("%d", "dd")
|
.replace("%d", "dd")
|
||||||
|
.replace("%H", "HH")
|
||||||
|
.replace("%i", "mm")
|
||||||
|
|
||||||
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
|
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3Client
|
||||||
|
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||||
|
import com.amazonaws.services.s3.model.PutObjectResult
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.util.AopTestUtils
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.RequestBuilder
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||||
|
import java.net.URL
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val chatCharacterService: ChatCharacterService,
|
||||||
|
private val audioContentService: AudioContentService,
|
||||||
|
private val transactionTemplate: TransactionTemplate,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var amazonS3Client: AmazonS3Client
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||||
|
|
||||||
|
private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun replaceActualServicePublisher() {
|
||||||
|
originalAudioContentServicePublisher = replaceAudioContentServicePublisher(applicationEventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun restoreActualServicePublisher() {
|
||||||
|
replaceAudioContentServicePublisher(originalAudioContentServicePublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("생성은 coverImage part 누락을 업로드 전 invalid request로 거부한다")
|
||||||
|
fun shouldRejectMissingCoverImageBeforeUpload(language: String, message: String) {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-missing-cover-character",
|
||||||
|
name = "v2-audio-create-missing-cover-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = saveTheme()
|
||||||
|
|
||||||
|
assertMissingPartRequest(
|
||||||
|
requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(requestPart(theme.id!!))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication()),
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"coverImage,ko,잘못된 요청입니다.",
|
||||||
|
"coverImage,en,Invalid request.",
|
||||||
|
"coverImage,ja,無効なリクエストです。",
|
||||||
|
"audioFile,ko,잘못된 요청입니다.",
|
||||||
|
"audioFile,en,Invalid request.",
|
||||||
|
"audioFile,ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다")
|
||||||
|
fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-empty-file-$filePart-$language",
|
||||||
|
name = "v2-audio-create-empty-file-$filePart-$language",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1)
|
||||||
|
val audioBytes = if (filePart == "audioFile") byteArrayOf() else byteArrayOf(1)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", coverBytes))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", audioBytes))
|
||||||
|
.file(requestPart(theme.id!!))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value(message))
|
||||||
|
|
||||||
|
assertActualAudioContentServicePublisher(applicationEventPublisher)
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
assertEquals(0L, countAudioContents())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("생성은 audioFile part 누락을 업로드 전 invalid request로 거부한다")
|
||||||
|
fun shouldRejectMissingAudioFileBeforeUpload(language: String, message: String) {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-missing-audio-character",
|
||||||
|
name = "v2-audio-create-missing-audio-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = saveTheme()
|
||||||
|
|
||||||
|
assertMissingPartRequest(
|
||||||
|
requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(requestPart(theme.id!!))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication()),
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("생성은 request part 누락을 업로드 전 invalid request로 거부한다")
|
||||||
|
fun shouldRejectMissingRequestBeforeUpload(language: String, message: String) {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-missing-request-character",
|
||||||
|
name = "v2-audio-create-missing-request-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertMissingPartRequest(
|
||||||
|
requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication()),
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다")
|
||||||
|
fun shouldRejectMissingThemeBeforeUpload() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-missing-theme-character",
|
||||||
|
name = "v2-audio-create-missing-theme-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(requestPart(999999L))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("Invalid theme. Please select again."))
|
||||||
|
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
assertEquals(0L, countAudioContents())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
@DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다")
|
||||||
|
fun shouldRollBackDatabaseAndEventWhenCoverUploadFails() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-cover-failure-character",
|
||||||
|
name = "v2-audio-create-cover-failure-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = transactionTemplate.execute { saveTheme() }!!
|
||||||
|
Mockito.doThrow(IllegalStateException("cover upload failure"))
|
||||||
|
.`when`(amazonS3Client)
|
||||||
|
.putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(requestPart(theme.id!!))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isInternalServerError)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("An unknown error occurred. try again."))
|
||||||
|
|
||||||
|
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
assertNull(findAudioContentByTitle("audio"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
@DisplayName("생성은 audio 업로드 실패 시 DB와 이벤트를 남기지 않고 cover 업로드 호출만 남긴다")
|
||||||
|
fun shouldRollBackDatabaseAndEventWhenAudioUploadFails() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-audio-failure-character",
|
||||||
|
name = "v2-audio-create-audio-failure-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = transactionTemplate.execute { saveTheme() }!!
|
||||||
|
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||||
|
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
|
||||||
|
Mockito.`when`(amazonS3Client.putObject(Mockito.any(PutObjectRequest::class.java)))
|
||||||
|
.thenReturn(PutObjectResult())
|
||||||
|
.thenThrow(IllegalStateException("audio upload failure"))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(requestPart(theme.id!!))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isInternalServerError)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("An unknown error occurred. try again."))
|
||||||
|
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
assertNull(findAudioContentByTitle("audio"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
@DisplayName("생성은 이벤트 발행 실패 시 DB를 롤백하고 두 S3 업로드 호출만 남긴다")
|
||||||
|
fun shouldRollBackDatabaseWhenCreateEventPublishFails() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-event-failure-character",
|
||||||
|
name = "v2-audio-create-event-failure-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = transactionTemplate.execute { saveTheme() }!!
|
||||||
|
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||||
|
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
|
||||||
|
val originalPublisher = replaceAudioContentServicePublisher(applicationEventPublisher)
|
||||||
|
Mockito.doThrow(IllegalStateException("event failure"))
|
||||||
|
.`when`(applicationEventPublisher)
|
||||||
|
.publishEvent(Mockito.any(Any::class.java))
|
||||||
|
|
||||||
|
try {
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(requestPart(theme.id!!))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isInternalServerError)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("An unknown error occurred. try again."))
|
||||||
|
} finally {
|
||||||
|
replaceAudioContentServicePublisher(originalPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java))
|
||||||
|
assertNull(findAudioContentByTitle("audio"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("생성은 tags 누락과 isActive true를 legacy pipeline 기본 계약으로 허용한다")
|
||||||
|
fun shouldAllowMissingTagsAndActiveTrueRequest() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-create-minimal-character",
|
||||||
|
name = "v2-audio-create-minimal-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val theme = saveTheme()
|
||||||
|
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||||
|
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(
|
||||||
|
MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.APPLICATION_JSON_VALUE,
|
||||||
|
"""
|
||||||
|
{"title":"minimal audio","description":"detail","price":100,"themeId":${theme.id},"isActive":true}
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.title").value("minimal audio"))
|
||||||
|
.andExpect(jsonPath("$.data.tag").value(""))
|
||||||
|
.andExpect(jsonPath("$.data.isActive").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveTheme(): AudioContentTheme {
|
||||||
|
val theme = AudioContentTheme(theme = "create-test-theme", image = "theme.png")
|
||||||
|
entityManager.persist(theme)
|
||||||
|
entityManager.flush()
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPart(themeId: Long): MockMultipartFile {
|
||||||
|
return MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.APPLICATION_JSON_VALUE,
|
||||||
|
"""
|
||||||
|
{"title":"audio","description":"detail","price":100,"themeId":$themeId,"isActive":true}
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertMissingPartRequest(
|
||||||
|
requestBuilder: RequestBuilder,
|
||||||
|
message: String
|
||||||
|
) {
|
||||||
|
val result = mockMvc.perform(requestBuilder)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value(message))
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass)
|
||||||
|
assertActualAudioContentServicePublisher(applicationEventPublisher)
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
assertEquals(0L, countAudioContents())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countAudioContents(): Long {
|
||||||
|
return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java)
|
||||||
|
.singleResult
|
||||||
|
.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAudioContentByTitle(title: String): AudioContent? {
|
||||||
|
return entityManager.createQuery(
|
||||||
|
"select c from AudioContent c where c.title = :title",
|
||||||
|
AudioContent::class.java
|
||||||
|
).setParameter("title", title).resultList.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceAudioContentServicePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher {
|
||||||
|
val target = AopTestUtils.getTargetObject<AudioContentService>(audioContentService)
|
||||||
|
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
|
||||||
|
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertActualAudioContentServicePublisher(publisher: ApplicationEventPublisher) {
|
||||||
|
val target = AopTestUtils.getTargetObject<AudioContentService>(audioContentService)
|
||||||
|
assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adminAuthentication() = authentication(
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = MemberRole.ADMIN
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3Client
|
||||||
|
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.hamcrest.Matchers.nullValue
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.Arguments
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.util.AopTestUtils
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.ResultActions
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import java.util.stream.Stream
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val audioContentService: AudioContentService,
|
||||||
|
private val creatorAdminContentService: CreatorAdminContentService,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var amazonS3Client: AmazonS3Client
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||||
|
|
||||||
|
private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher
|
||||||
|
private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun replaceActualServicePublishers() {
|
||||||
|
originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher)
|
||||||
|
originalCreatorAdminContentServicePublisher = replacePublisher(
|
||||||
|
creatorAdminContentService,
|
||||||
|
applicationEventPublisher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun restoreActualServicePublishers() {
|
||||||
|
replacePublisher(audioContentService, originalAudioContentServicePublisher)
|
||||||
|
replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("malformedIdentifierRequests")
|
||||||
|
@DisplayName("숫자가 아닌 콘텐츠 식별자는 미매핑 404 i18n envelope으로 처리한다")
|
||||||
|
fun shouldReturnNotFoundEnvelopeForMalformedContentIdentifiers(method: HttpMethod, path: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
request(method, path)
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(404, "Invalid request.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("phase3EndpointRequests")
|
||||||
|
@DisplayName("실제 Phase 3 endpoint는 JWT ADMIN이 아니면 binding 전에 거부한다")
|
||||||
|
fun shouldRejectNonAdminJwtRoleBeforePhase3EndpointBinding(method: HttpMethod, path: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
request(method, path)
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(authentication(MemberRole.USER, MemberRole.ADMIN))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(403, "You do not have permission.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("phase3EndpointRequests")
|
||||||
|
@DisplayName("실제 Phase 3 endpoint는 stale ADMIN claim을 binding 전에 거부한다")
|
||||||
|
fun shouldRejectStaleAdminClaimBeforePhase3EndpointBinding(method: HttpMethod, path: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
request(method, path)
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.with(authentication(MemberRole.ADMIN, MemberRole.USER))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(403, "You do not have permission.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("존재하지 않는 characterId 생성은 S3 업로드 전에 invalid request로 거부한다")
|
||||||
|
fun shouldRejectUnknownCharacterBeforeCreateUpload(language: String, message: String) {
|
||||||
|
val beforeAudioContents = countAudioContents()
|
||||||
|
val beforeSeriesContents = countSeriesContents()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/999999/audio-contents")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
|
||||||
|
.file(
|
||||||
|
MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.APPLICATION_JSON_VALUE,
|
||||||
|
"{}".toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertNoSideEffects(beforeAudioContents, beforeSeriesContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("존재하지 않는 characterId 목록은 DB/S3/event 변경 없이 invalid request로 거부한다")
|
||||||
|
fun shouldRejectUnknownCharacterBeforeListSideEffects(language: String, message: String) {
|
||||||
|
val beforeAudioContents = countAudioContents()
|
||||||
|
val beforeSeriesContents = countSeriesContents()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
request(HttpMethod.GET, "/api/v2/admin/ai-characters/999999/audio-contents")
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertNoSideEffects(beforeAudioContents, beforeSeriesContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("존재하지 않는 characterId 상세는 DB/S3/event 변경 없이 invalid request로 거부한다")
|
||||||
|
fun shouldRejectUnknownCharacterBeforeDetailSideEffects(language: String, message: String) {
|
||||||
|
val beforeAudioContents = countAudioContents()
|
||||||
|
val beforeSeriesContents = countSeriesContents()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
request(HttpMethod.GET, "/api/v2/admin/ai-characters/999999/audio-contents/1")
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertNoSideEffects(beforeAudioContents, beforeSeriesContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("존재하지 않는 characterId 수정은 DB/S3/event 변경 없이 invalid request로 거부한다")
|
||||||
|
fun shouldRejectUnknownCharacterBeforeUpdateSideEffects(language: String, message: String) {
|
||||||
|
val beforeAudioContents = countAudioContents()
|
||||||
|
val beforeSeriesContents = countSeriesContents()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/999999/audio-contents/1")
|
||||||
|
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray()))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertNoSideEffects(beforeAudioContents, beforeSeriesContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoSideEffects(beforeAudioContents: Long, beforeSeriesContents: Long) {
|
||||||
|
assertActualPublisher(applicationEventPublisher, audioContentService)
|
||||||
|
assertActualPublisher(applicationEventPublisher, creatorAdminContentService)
|
||||||
|
assertEquals(beforeAudioContents, countAudioContents())
|
||||||
|
assertEquals(beforeSeriesContents, countSeriesContents())
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replacePublisher(service: Any, publisher: ApplicationEventPublisher): ApplicationEventPublisher {
|
||||||
|
val target = AopTestUtils.getTargetObject<Any>(service)
|
||||||
|
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
|
||||||
|
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertActualPublisher(publisher: ApplicationEventPublisher, service: Any) {
|
||||||
|
val target = AopTestUtils.getTargetObject<Any>(service)
|
||||||
|
assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("phase3EndpointRequests")
|
||||||
|
@DisplayName("실제 Phase 3 endpoint preflight는 캐릭터 관리자 Origin만 허용한다")
|
||||||
|
fun shouldApplyCorsPreflightToEveryPhase3Endpoint(method: HttpMethod, path: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
options(path)
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method.name)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
options(path)
|
||||||
|
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method.name)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 테마 preflight는 캐릭터 관리자 Origin만 허용한다")
|
||||||
|
fun shouldAllowOnlyCharacterAdminOriginForThemePreflight() {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/audio-content-themes")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/audio-content-themes")
|
||||||
|
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authentication(jwtRole: MemberRole, currentRole: MemberRole) = authentication(
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = currentRole
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun adminAuthentication() = authentication(MemberRole.ADMIN, MemberRole.ADMIN)
|
||||||
|
|
||||||
|
private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) {
|
||||||
|
andExpect(status().`is`(httpStatus))
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value(message))
|
||||||
|
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countAudioContents(): Long {
|
||||||
|
return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java)
|
||||||
|
.singleResult
|
||||||
|
.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countSeriesContents(): Long {
|
||||||
|
return entityManager.createQuery("select count(sc) from SeriesContent sc", java.lang.Long::class.java)
|
||||||
|
.singleResult
|
||||||
|
.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
|
||||||
|
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun malformedIdentifierRequests(): Stream<Arguments> = Stream.of(
|
||||||
|
Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/not-a-number/audio-contents"),
|
||||||
|
Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/not-a-number/audio-contents/1"),
|
||||||
|
Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents/not-a-number"),
|
||||||
|
Arguments.of(HttpMethod.POST, "/api/v2/admin/ai-characters/not-a-number/audio-contents"),
|
||||||
|
Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/not-a-number/audio-contents/1"),
|
||||||
|
Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/audio-contents/not-a-number")
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun phase3EndpointRequests(): Stream<Arguments> = Stream.of(
|
||||||
|
Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/audio-content-themes"),
|
||||||
|
Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents"),
|
||||||
|
Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents/1"),
|
||||||
|
Arguments.of(HttpMethod.POST, "/api/v2/admin/ai-characters/1/audio-contents"),
|
||||||
|
Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/audio-contents/1")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.PurchaseOption
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val chatCharacterService: ChatCharacterService,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상세는 과거 공개일을 releaseDateUtc에만 반환하고 legacy releaseDate는 비운다")
|
||||||
|
fun shouldExposePastReleaseDateOnlyAsUtcField() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-query-release-character",
|
||||||
|
name = "v2-audio-query-release-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val content = saveAudioContent(
|
||||||
|
owner = character.creatorMember!!,
|
||||||
|
title = "query release audio",
|
||||||
|
releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0)
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
|
.thenReturn("https://signed.example.com/query-release.mp3?Expires=1")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.releaseDateUtc").value("2026-07-25T00:00:00Z"))
|
||||||
|
.andExpect(jsonPath("$.data.releaseDate").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data.isOnlyRental").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("상세 응답 DTO는 legacy/public 중첩 DTO 타입을 직접 노출하지 않는다")
|
||||||
|
fun shouldUseV2NestedResponseTypes() {
|
||||||
|
val legacyPackages = setOf(
|
||||||
|
"kr.co.vividnext.sodalive.content",
|
||||||
|
"kr.co.vividnext.sodalive.content.comment",
|
||||||
|
"kr.co.vividnext.sodalive.content.translation"
|
||||||
|
)
|
||||||
|
|
||||||
|
val nestedResponseFieldNames = setOf(
|
||||||
|
"creatorOtherContentList",
|
||||||
|
"sameThemeOtherContentList",
|
||||||
|
"commentList",
|
||||||
|
"creator",
|
||||||
|
"previousContent",
|
||||||
|
"nextContent",
|
||||||
|
"buyerList",
|
||||||
|
"translated"
|
||||||
|
)
|
||||||
|
val leakedTypes = AiCharacterAdminAudioContentResponse::class.java.declaredFields
|
||||||
|
.filter { it.name in nestedResponseFieldNames }
|
||||||
|
.map { it.type.packageName }
|
||||||
|
.filter { it in legacyPackages }
|
||||||
|
|
||||||
|
assertFalse(leakedTypes.isNotEmpty(), "legacy nested DTO packages leaked: $leakedTypes")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent {
|
||||||
|
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return AudioContent(
|
||||||
|
title = title,
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
price = 100,
|
||||||
|
purchaseOption = PurchaseOption.RENT_ONLY,
|
||||||
|
isOnlyRental = false,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isCommentAvailable = true
|
||||||
|
).apply {
|
||||||
|
member = owner
|
||||||
|
this.theme = theme
|
||||||
|
isActive = true
|
||||||
|
this.releaseDate = releaseDate
|
||||||
|
duration = "01:10:00"
|
||||||
|
content = "private/$title.mp3"
|
||||||
|
coverImage = "cover/$title.png"
|
||||||
|
entityManager.persist(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adminAuthentication() = authentication(
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = MemberRole.ADMIN
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 v2 필드명으로 반환한다")
|
||||||
|
fun shouldReturnActiveThemesWithV2FieldNames() {
|
||||||
|
val later = AudioContentTheme(theme = "later theme", image = "theme/later.png", orders = 2)
|
||||||
|
val earlier = AudioContentTheme(theme = "earlier theme", image = "theme/earlier.png", orders = 1)
|
||||||
|
val inactive = AudioContentTheme(theme = "inactive theme", image = "theme/inactive.png", isActive = false)
|
||||||
|
entityManager.persist(later)
|
||||||
|
entityManager.persist(earlier)
|
||||||
|
entityManager.persist(inactive)
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/audio-content-themes")
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data[0].themeId").value(earlier.id))
|
||||||
|
.andExpect(jsonPath("$.data[0].themeName").value("earlier theme"))
|
||||||
|
.andExpect(jsonPath("$.data[0].imageUrl").value("https://test.cloudfront.net/theme/earlier.png"))
|
||||||
|
.andExpect(jsonPath("$.data[0].id").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data[0].theme").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data[0].image").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 테마 목록은 ADMIN 인증을 요구한다")
|
||||||
|
fun shouldRequireAdminAuthentication() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/audio-content-themes")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adminAuthentication() = authentication(
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = MemberRole.ADMIN
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3Client
|
||||||
|
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.util.AopTestUtils
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val chatCharacterService: ChatCharacterService,
|
||||||
|
private val creatorAdminContentService: CreatorAdminContentService,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var amazonS3Client: AmazonS3Client
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||||
|
|
||||||
|
private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun replaceActualServicePublisher() {
|
||||||
|
originalCreatorAdminContentServicePublisher = replaceCreatorAdminContentServicePublisher(applicationEventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun restoreActualServicePublisher() {
|
||||||
|
replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("수정은 동일 seriesIds의 기존 연결 metadata를 보존한다")
|
||||||
|
fun shouldPreserveExistingSeriesContentMetadataForSameSeriesIds() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-update-preserve-series-character",
|
||||||
|
name = "v2-audio-update-preserve-series-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val series = saveSeries("preserve-series", character.creatorMember!!)
|
||||||
|
val content = saveAudioContent(character.creatorMember!!, "preserve-series-content")
|
||||||
|
val seriesContent = saveSeriesContent(series, content).apply { orders = 7 }
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
val beforeId = seriesContent.id
|
||||||
|
val beforeCreatedAt = seriesContent.createdAt
|
||||||
|
entityManager.clear()
|
||||||
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
|
.thenReturn("https://test.cloudfront.net/private/preserve-series-content.mp3")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(
|
||||||
|
MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.TEXT_PLAIN_VALUE,
|
||||||
|
"""{"seriesIds":[${series.id}]}""".toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.seriesIds[0]").value(series.id))
|
||||||
|
|
||||||
|
val saved = findSeriesContents(content.id!!).single()
|
||||||
|
assertEquals(beforeId, saved.id)
|
||||||
|
assertEquals(7, saved.orders)
|
||||||
|
assertEquals(beforeCreatedAt, saved.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("수정은 seriesIds 교집합 metadata를 보존하고 차집합만 추가·제거한다")
|
||||||
|
fun shouldOnlyInsertAndDeleteSeriesContentDifference() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-update-diff-series-character",
|
||||||
|
name = "v2-audio-update-diff-series-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val removedSeries = saveSeries("removed-series", character.creatorMember!!)
|
||||||
|
val keptSeries = saveSeries("kept-series", character.creatorMember!!)
|
||||||
|
val addedSeries = saveSeries("added-series", character.creatorMember!!)
|
||||||
|
val content = saveAudioContent(character.creatorMember!!, "diff-series-content")
|
||||||
|
saveSeriesContent(removedSeries, content)
|
||||||
|
val keptSeriesContent = saveSeriesContent(keptSeries, content).apply { orders = 5 }
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
val keptId = keptSeriesContent.id
|
||||||
|
val keptCreatedAt = keptSeriesContent.createdAt
|
||||||
|
entityManager.clear()
|
||||||
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
|
.thenReturn("https://test.cloudfront.net/private/diff-series-content.mp3")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(
|
||||||
|
MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.TEXT_PLAIN_VALUE,
|
||||||
|
"""{"seriesIds":[${keptSeries.id},${addedSeries.id}]}""".toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.seriesIds.length()").value(2))
|
||||||
|
|
||||||
|
val saved = findSeriesContents(content.id!!)
|
||||||
|
assertEquals(setOf(keptSeries.id, addedSeries.id), saved.map { it.series!!.id }.toSet())
|
||||||
|
val kept = saved.single { it.series!!.id == keptSeries.id }
|
||||||
|
assertEquals(keptId, kept.id)
|
||||||
|
assertEquals(5, kept.orders)
|
||||||
|
assertEquals(keptCreatedAt, kept.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("수정은 cover가 없으면 유지하고 성공 교체 시 새 cover path를 저장한다")
|
||||||
|
fun shouldKeepOrReplaceCoverImage() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-update-cover-character",
|
||||||
|
name = "v2-audio-update-cover-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val content = saveAudioContent(character.creatorMember!!, "cover-update-content")
|
||||||
|
val oldCover = content.coverImage
|
||||||
|
entityManager.flush()
|
||||||
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
|
.thenReturn("https://test.cloudfront.net/private/cover-update-content.mp3")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray()))
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
assertEquals(oldCover, entityManager.find(AudioContent::class.java, content.id).coverImage)
|
||||||
|
|
||||||
|
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||||
|
.thenReturn(URL("https://test.cloudfront.net/cover-updated"))
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray()))
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
val updatedCover = entityManager.find(AudioContent::class.java, content.id).coverImage!!
|
||||||
|
assertTrue(updatedCover.startsWith("audio_content_cover/${content.id}/"))
|
||||||
|
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("수정은 빈 coverImage를 생략과 동일하게 처리한다")
|
||||||
|
fun shouldTreatEmptyCoverImageAsAbsent() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-update-empty-cover-character",
|
||||||
|
name = "v2-audio-update-empty-cover-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val content = saveAudioContent(character.creatorMember!!, "empty-cover-update-content")
|
||||||
|
val oldCover = content.coverImage
|
||||||
|
entityManager.flush()
|
||||||
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
|
.thenReturn("https://test.cloudfront.net/private/empty-cover-update-content.mp3")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(MockMultipartFile("coverImage", "empty.png", "image/png", byteArrayOf()))
|
||||||
|
.file(MockMultipartFile("request", "request.json", MediaType.TEXT_PLAIN_VALUE, "{}".toByteArray()))
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
assertEquals(oldCover, entityManager.find(AudioContent::class.java, content.id).coverImage)
|
||||||
|
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("수정은 빈 audioFile part도 교체 요청으로 거부한다")
|
||||||
|
fun shouldRejectEmptyAudioFileUpdate() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-update-empty-audio-character",
|
||||||
|
name = "v2-audio-update-empty-audio-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val content = saveAudioContent(character.creatorMember!!, "empty-audio-update-content")
|
||||||
|
val oldTitle = content.title
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(MockMultipartFile("audioFile", "empty.mp3", "audio/mpeg", byteArrayOf()))
|
||||||
|
.file(
|
||||||
|
MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.TEXT_PLAIN_VALUE,
|
||||||
|
"""{"title":"should not change"}""".toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
|
||||||
|
assertEquals(oldTitle, entityManager.find(AudioContent::class.java, content.id).title)
|
||||||
|
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("수정은 cover 업로드 실패 시 DB를 변경하지 않는다")
|
||||||
|
fun shouldKeepDatabaseStateWhenCoverUploadFails() {
|
||||||
|
val character = chatCharacterService.createChatCharacterWithDetails(
|
||||||
|
characterUUID = "v2-audio-update-cover-failure-character",
|
||||||
|
name = "v2-audio-update-cover-failure-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
)
|
||||||
|
val content = saveAudioContent(character.creatorMember!!, "cover-failure-content")
|
||||||
|
val oldCover = content.coverImage
|
||||||
|
entityManager.flush()
|
||||||
|
Mockito.doThrow(IllegalStateException("cover upload failure"))
|
||||||
|
.`when`(amazonS3Client)
|
||||||
|
.putObject(Mockito.any(PutObjectRequest::class.java))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
|
||||||
|
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
|
||||||
|
.file(
|
||||||
|
MockMultipartFile(
|
||||||
|
"request",
|
||||||
|
"request.json",
|
||||||
|
MediaType.TEXT_PLAIN_VALUE,
|
||||||
|
"""{"title":"cover failure after title"}""".toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with(adminAuthentication())
|
||||||
|
)
|
||||||
|
.andExpect(status().isInternalServerError)
|
||||||
|
|
||||||
|
val saved = entityManager.find(AudioContent::class.java, content.id)
|
||||||
|
assertEquals("cover-failure-content", saved.title)
|
||||||
|
assertEquals(oldCover, saved.coverImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(owner: Member, title: String): AudioContent {
|
||||||
|
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return AudioContent(
|
||||||
|
title = title,
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
price = 100,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isCommentAvailable = true
|
||||||
|
).apply {
|
||||||
|
member = owner
|
||||||
|
this.theme = theme
|
||||||
|
isActive = true
|
||||||
|
releaseDate = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime()
|
||||||
|
duration = "01:10:00"
|
||||||
|
content = "private/$title.mp3"
|
||||||
|
coverImage = "cover/$title.png"
|
||||||
|
entityManager.persist(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeries(title: String, owner: Member): Series {
|
||||||
|
val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true)
|
||||||
|
entityManager.persist(genre)
|
||||||
|
return Series(title = title, introduction = "introduction", languageCode = "ko").apply {
|
||||||
|
member = owner
|
||||||
|
this.genre = genre
|
||||||
|
coverImage = "$title.png"
|
||||||
|
entityManager.persist(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||||
|
return SeriesContent().apply {
|
||||||
|
this.series = series
|
||||||
|
this.content = content
|
||||||
|
entityManager.persist(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSeriesContents(contentId: Long): List<SeriesContent> {
|
||||||
|
return entityManager.createQuery(
|
||||||
|
"select sc from SeriesContent sc where sc.content.id = :contentId",
|
||||||
|
SeriesContent::class.java
|
||||||
|
).setParameter("contentId", contentId).resultList
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceCreatorAdminContentServicePublisher(
|
||||||
|
publisher: ApplicationEventPublisher
|
||||||
|
): ApplicationEventPublisher {
|
||||||
|
val target = AopTestUtils.getTargetObject<CreatorAdminContentService>(creatorAdminContentService)
|
||||||
|
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
|
||||||
|
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertActualCreatorAdminContentServicePublisher(publisher: ApplicationEventPublisher) {
|
||||||
|
val target = AopTestUtils.getTargetObject<CreatorAdminContentService>(creatorAdminContentService)
|
||||||
|
assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adminAuthentication() = authentication(
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = MemberRole.ADMIN
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.net.URI
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
class AudioContentCloudFrontCharacterizationTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 AudioContentCloudFront는 요청한 밀리초 TTL과 중첩 경로로 signed URL을 만든다")
|
||||||
|
fun shouldUseRequestedTtlAndNestedResourcePath() {
|
||||||
|
withCloudFront { cloudFront, privateKeyMaterial ->
|
||||||
|
val before = System.currentTimeMillis() / 1_000
|
||||||
|
|
||||||
|
val signedUrl = cloudFront.generateSignedURL("audio/nested/clip.mp3", 3_600_000L)
|
||||||
|
|
||||||
|
val after = System.currentTimeMillis() / 1_000
|
||||||
|
val uri = URI(signedUrl)
|
||||||
|
val expires = queryParameter(uri, "Expires").toLong()
|
||||||
|
assertTrue(expires in (before + 3_599)..(after + 3_601))
|
||||||
|
assertEquals("/audio/nested/clip.mp3", uri.path)
|
||||||
|
assertFalse(signedUrl.contains(privateKeyMaterial))
|
||||||
|
assertFalse(signedUrl.contains("BEGIN PRIVATE KEY"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 AudioContentCloudFront는 선행 slash를 정규화하지 않고 즉시 만료 URL도 서명한다")
|
||||||
|
fun shouldPreserveLeadingSlashAndAllowZeroTtl() {
|
||||||
|
withCloudFront { cloudFront, _ ->
|
||||||
|
val before = System.currentTimeMillis() / 1_000
|
||||||
|
|
||||||
|
val signedUrl = cloudFront.generateSignedURL("/audio/edge.mp3", 0L)
|
||||||
|
|
||||||
|
val after = System.currentTimeMillis() / 1_000
|
||||||
|
val uri = URI(signedUrl)
|
||||||
|
val expires = queryParameter(uri, "Expires").toLong()
|
||||||
|
assertEquals("//audio/edge.mp3", uri.path)
|
||||||
|
assertTrue(expires in (before - 1)..(after + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withCloudFront(block: (AudioContentCloudFront, String) -> Unit) {
|
||||||
|
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1_024) }.generateKeyPair()
|
||||||
|
val keyFile = Files.createTempFile("audio-content-cloudfront", ".der")
|
||||||
|
Files.write(keyFile, keyPair.private.encoded)
|
||||||
|
|
||||||
|
try {
|
||||||
|
block(
|
||||||
|
AudioContentCloudFront("https://cdn.example.com", keyFile.toString(), "test-key-pair"),
|
||||||
|
Base64.getEncoder().encodeToString(keyPair.private.encoded)
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(keyFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryParameter(uri: URI, name: String): String {
|
||||||
|
return uri.rawQuery
|
||||||
|
.split("&")
|
||||||
|
.map { it.split("=", limit = 2) }
|
||||||
|
.single { it[0] == name }[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3Client
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentPriceChangeLogRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor(
|
||||||
|
private val audioContentService: AudioContentService,
|
||||||
|
private val repository: CreatorAdminContentRepository,
|
||||||
|
private val hashTagRepository: HashTagRepository,
|
||||||
|
private val contentPriceChangeLogRepository: ContentPriceChangeLogRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var amazonS3Client: AmazonS3Client
|
||||||
|
|
||||||
|
private lateinit var s3Uploader: S3Uploader
|
||||||
|
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||||
|
private lateinit var service: CreatorAdminContentService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
registerMysqlDateFunctions()
|
||||||
|
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||||
|
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||||
|
.thenReturn(URL("https://s3.example.com/test"))
|
||||||
|
s3Uploader = S3Uploader(amazonS3Client)
|
||||||
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
service = CreatorAdminContentService(
|
||||||
|
repository = repository,
|
||||||
|
hashTagRepository = hashTagRepository,
|
||||||
|
contentPriceChangeLogRepository = contentPriceChangeLogRepository,
|
||||||
|
audioContentCloudFront = audioContentCloudFront,
|
||||||
|
objectMapper = jacksonObjectMapper(),
|
||||||
|
s3Uploader = s3Uploader,
|
||||||
|
applicationEventPublisher = applicationEventPublisher,
|
||||||
|
bucket = "test-bucket",
|
||||||
|
coverImageHost = "https://cover.example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 콘텐츠 생성은 표지와 오디오를 업로드하고 처리 대기 콘텐츠를 저장한다")
|
||||||
|
fun shouldCreateContentThroughLegacyUploadPipeline() {
|
||||||
|
val owner = saveMember("legacy-create-owner")
|
||||||
|
val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png")
|
||||||
|
entityManager.persist(theme)
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
val response = audioContentService.createAudioContent(
|
||||||
|
contentFile = MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1, 2, 3)),
|
||||||
|
coverImage = MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(4, 5, 6)),
|
||||||
|
requestString = """
|
||||||
|
{"title":"legacy created audio","detail":"created detail","tags":"#legacy","price":100,"themeId":${theme.id},"isAdult":false,"languageCode":"ko"}
|
||||||
|
""".trimIndent(),
|
||||||
|
member = owner
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
val content = entityManager.find(AudioContent::class.java, response.contentId)
|
||||||
|
assertEquals("legacy created audio", content.title)
|
||||||
|
assertEquals(owner.id, content.member!!.id)
|
||||||
|
assertEquals(theme.id, content.theme!!.id)
|
||||||
|
assertFalse(content.isActive)
|
||||||
|
assertTrue(content.coverImage!!.startsWith("audio_content_cover/${response.contentId}/"))
|
||||||
|
assertTrue(content.content!!.startsWith("input/${response.contentId}/"))
|
||||||
|
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 크리에이터 콘텐츠 목록과 검색은 소유자, 처리 완료, 예약 상태를 구분하고 signed URL로 교체한다")
|
||||||
|
fun shouldListAndSearchOnlyOwnersProcessedOrReservedContentWithSignedUrl() {
|
||||||
|
val owner = saveMember("legacy-content-owner")
|
||||||
|
val otherOwner = saveMember("legacy-content-other-owner")
|
||||||
|
val published = saveAudioContent(owner, "published legacy audio", "01:10:00")
|
||||||
|
val reserved = saveAudioContent(owner, "reserved legacy audio", "00:10:00").apply {
|
||||||
|
isActive = false
|
||||||
|
releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0)
|
||||||
|
}
|
||||||
|
saveAudioContent(owner, "processing legacy audio", "00:10:00").duration = null
|
||||||
|
saveAudioContent(otherOwner, "published legacy audio", "01:10:00")
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||||
|
.thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" }
|
||||||
|
|
||||||
|
val list = service.getAudioContentList(PageRequest.of(0, 10), owner)
|
||||||
|
val search = service.searchAudioContent("published", owner, PageRequest.of(0, 10))
|
||||||
|
|
||||||
|
assertEquals(2, list.totalCount)
|
||||||
|
assertEquals(setOf(published.id, reserved.id), list.items.map { it.audioContentId }.toSet())
|
||||||
|
assertEquals(1, search.totalCount)
|
||||||
|
assertEquals(listOf(published.id), search.items.map { it.audioContentId })
|
||||||
|
assertEquals(
|
||||||
|
"https://signed.example.com/${published.content}?Expires=1",
|
||||||
|
list.items.single { it.audioContentId == published.id }.contentUrl
|
||||||
|
)
|
||||||
|
assertNotEquals(published.content, list.items.single { it.audioContentId == published.id }.contentUrl)
|
||||||
|
Mockito.verify(audioContentCloudFront, Mockito.times(2))
|
||||||
|
.generateSignedURL(published.content!!, 10_800_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 크리에이터 콘텐츠 검색은 한 글자 검색어를 거부한다")
|
||||||
|
fun shouldRejectOneCharacterSearchWord() {
|
||||||
|
val owner = saveMember("legacy-search-owner")
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.searchAudioContent("a", owner, PageRequest.of(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("creator.admin.content.search_word_min_length", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 크리에이터 콘텐츠 수정은 가격과 플래그를 저장하고 비활성화 시 예약을 해제하며 번역 이벤트를 발행한다")
|
||||||
|
fun shouldUpdatePriceFlagsSoftDeleteAndTranslationEvent() {
|
||||||
|
val owner = saveMember("legacy-update-owner")
|
||||||
|
val content = saveAudioContent(owner, "before title", "00:10:00").apply {
|
||||||
|
price = 100
|
||||||
|
releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":${content.id},"title":"after title","detail":"after detail","tags":"#updated updated","price":250,"isAdult":true,"isPointAvailable":true,"isCommentAvailable":false,"isActive":false}
|
||||||
|
""".trimIndent(),
|
||||||
|
member = owner
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
|
||||||
|
assertEquals("after title", content.title)
|
||||||
|
assertEquals("after detail", content.detail)
|
||||||
|
assertEquals(250, content.price)
|
||||||
|
assertTrue(content.isAdult)
|
||||||
|
assertTrue(content.isPointAvailable)
|
||||||
|
assertFalse(content.isCommentAvailable)
|
||||||
|
assertFalse(content.isActive)
|
||||||
|
assertNull(content.releaseDate)
|
||||||
|
assertEquals(100, contentPriceChangeLogRepository.findAll().single().prevPrice)
|
||||||
|
assertEquals(listOf("#updated"), content.audioContentHashTags.map { it.hashTag!!.tag })
|
||||||
|
|
||||||
|
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
||||||
|
Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture())
|
||||||
|
val event = eventCaptor.value as LanguageTranslationEvent
|
||||||
|
assertEquals(content.id, event.id)
|
||||||
|
assertEquals(LanguageTranslationTargetType.CONTENT, event.targetType)
|
||||||
|
assertTrue(event.waitTransactionCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 크리에이터 콘텐츠 표지 수정은 생성된 uploader 객체 경로를 저장한다")
|
||||||
|
fun shouldStoreGeneratedUploaderCoverPath() {
|
||||||
|
val owner = saveMember("legacy-cover-owner")
|
||||||
|
val content = saveAudioContent(owner, "cover legacy audio", "00:10:00")
|
||||||
|
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)),
|
||||||
|
requestString = """{"id":${content.id}}""",
|
||||||
|
member = owner
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(content.coverImage!!.startsWith("audio_content_cover/${content.id}/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
role = MemberRole.CREATOR
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
entityManager.flush()
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(owner: Member, title: String, duration: String): AudioContent {
|
||||||
|
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return AudioContent(
|
||||||
|
title = title,
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
limited = 10,
|
||||||
|
remaining = 5
|
||||||
|
).apply {
|
||||||
|
member = owner
|
||||||
|
this.theme = theme
|
||||||
|
isActive = true
|
||||||
|
releaseDate = LocalDateTime.of(2026, 7, 24, 10, 0)
|
||||||
|
this.duration = duration
|
||||||
|
content = "audio/${title.replace(" ", "-")}.mp3"
|
||||||
|
coverImage = "audio/${title.replace(" ", "-")}.png"
|
||||||
|
entityManager.persist(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerMysqlDateFunctions() {
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||||
|
).executeUpdate()
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||||
|
).executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user