feat(ai-character): 오디오 콘텐츠 관리자 API를 추가한다

This commit is contained in:
2026-07-28 01:33:05 +09:00
parent 90c0182ae1
commit ec4e4a5768
15 changed files with 3468 additions and 0 deletions
@@ -29,6 +29,8 @@ class H2MysqlDateFunctions {
.replace("%Y", "yyyy")
.replace("%m", "MM")
.replace("%d", "dd")
.replace("%H", "HH")
.replace("%i", "mm")
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
}
@@ -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()
}
}