feat(ai-character): 오디오 콘텐츠 관리자 계약을 보강한다

This commit is contained in:
2026-07-30 00:30:53 +09:00
parent 4479c192c9
commit 7b652400b7
14 changed files with 1693 additions and 603 deletions
@@ -30,7 +30,10 @@ class H2MysqlDateFunctions {
.replace("%m", "MM")
.replace("%d", "dd")
.replace("%H", "HH")
.replace("%W", "EEEE")
.replace("%h", "hh")
.replace("%i", "mm")
.replace("%p", "a")
return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern))
}
@@ -0,0 +1,387 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.MediaType
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminAudioContentCommentTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val entityManager: EntityManager
) {
@MockBean
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@Test
@DisplayName("원댓글과 답글 목록은 timezone 없이 UTC Z date와 기존 totalCount 및 items를 반환한다")
fun shouldListRootCommentsAndRepliesWithUtcDates() {
registerMysqlDateFunctions()
val character = createCharacter("comment-list")
val content = saveAudioContent(character.creatorMember!!, "comment-list-content")
val root = saveComment(content, character.creatorMember!!, "root")
val reply = saveComment(content, character.creatorMember!!, "reply", root)
root.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 1)
reply.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 2)
entityManager.flush()
mockMvc.perform(
get(commentsPath(character.id!!, content.id!!))
.param("page", "0")
.param("size", "10")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(root.id))
.andExpect(jsonPath("$.data.items[0].comment").value("root"))
.andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:01Z"))
mockMvc.perform(
get("${commentsPath(character.id!!, content.id!!)}/${root.id}/replies")
.param("page", "0")
.param("size", "10")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].comment").value("reply"))
.andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:02Z"))
}
@Test
@DisplayName("댓글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다")
fun shouldDefaultMissingCommentListPaginationParameters() {
registerMysqlDateFunctions()
val character = createCharacter("comment-default-page")
val content = saveAudioContent(character.creatorMember!!, "comment-default-page-content")
repeat(21) { index ->
saveComment(content, character.creatorMember!!, "root-$index")
}
entityManager.flush()
val path = commentsPath(character.id!!, content.id!!)
mockMvc.perform(get(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(21))
.andExpect(jsonPath("$.data.items.length()").value(20))
mockMvc.perform(get(path).param("page", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
mockMvc.perform(get(path).param("size", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
}
@Test
@DisplayName("답글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다")
fun shouldDefaultMissingReplyListPaginationParameters() {
registerMysqlDateFunctions()
val character = createCharacter("reply-default-page")
val content = saveAudioContent(character.creatorMember!!, "reply-default-page-content")
val root = saveComment(content, character.creatorMember!!, "root")
repeat(21) { index ->
saveComment(content, character.creatorMember!!, "reply-$index", root)
}
entityManager.flush()
val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}/replies"
mockMvc.perform(get(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(21))
.andExpect(jsonPath("$.data.items.length()").value(20))
mockMvc.perform(get(path).param("page", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
mockMvc.perform(get(path).param("size", "1").with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items.length()").value(1))
}
@Test
@DisplayName("작성은 target AI를 writer로 사용하고 root와 답글 모두 data null을 반환한다")
fun shouldCreateRootAndReplyAsTargetAi() {
val character = createCharacter("comment-create")
val content = saveAudioContent(character.creatorMember!!, "comment-create-content")
entityManager.flush()
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"root", "languageCode":"ko"}""")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
val root = commentsFor(content.id!!).single()
assertEquals(character.creatorMember!!.id, root.member!!.id)
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"reply", "parentId":${root.id}, "isSecret":false, "languageCode":"ko"}""")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
val reply = commentsFor(content.id!!).single { it.parent?.id == root.id }
assertEquals(character.creatorMember!!.id, reply.member!!.id)
}
@Test
@DisplayName("다른 콘텐츠 또는 비활성 parent 답글 작성은 insert 없이 거부한다")
fun shouldRejectInvalidReplyParentWithoutInsert() {
val character = createCharacter("comment-parent")
val content = saveAudioContent(character.creatorMember!!, "comment-parent-content")
val otherContent = saveAudioContent(character.creatorMember!!, "comment-other-content")
val foreignParent = saveComment(otherContent, character.creatorMember!!, "other root")
val inactiveParent = saveComment(content, character.creatorMember!!, "inactive root").apply { isActive = false }
entityManager.flush()
val beforeCount = commentsFor(content.id!!).size
listOf(foreignParent.id!!, inactiveParent.id!!).forEach { parentId ->
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"must not persist", "parentId":$parentId}""")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
}
assertEquals(beforeCount, commentsFor(content.id!!).size)
}
@Test
@DisplayName("target AI가 작성하지 않은 활성 댓글 수정은 거부하고 원문을 유지한다")
fun shouldRejectFanAuthoredCommentUpdate() {
val character = createCharacter("comment-update")
val content = saveAudioContent(character.creatorMember!!, "comment-update-content")
val fanComment = saveComment(content, saveMember("comment-update-fan"), "fan comment")
entityManager.flush()
mockMvc.perform(
put("${commentsPath(character.id!!, content.id!!)}/${fanComment.id}")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"changed"}""")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals("fan comment", entityManager.find(AudioContentComment::class.java, fanComment.id).comment)
}
@Test
@DisplayName("target AI가 작성한 활성 댓글 수정은 data null로 완료한다")
fun shouldUpdateTargetAiAuthoredComment() {
val character = createCharacter("comment-update-target")
val content = saveAudioContent(character.creatorMember!!, "comment-update-target-content")
val comment = saveComment(content, character.creatorMember!!, "before")
entityManager.flush()
mockMvc.perform(
put("${commentsPath(character.id!!, content.id!!)}/${comment.id}")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"comment":"after"}""")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
assertEquals("after", entityManager.find(AudioContentComment::class.java, comment.id).comment)
}
@Test
@DisplayName("삭제는 대상 콘텐츠의 한 row만 비활성화하고 이미 비활성이면 성공 no-op이다")
fun shouldSoftDeleteOnlyTargetRowAndIgnoreInactiveComment() {
val character = createCharacter("comment-delete")
val content = saveAudioContent(character.creatorMember!!, "comment-delete-content")
val fan = saveMember("comment-delete-fan")
val root = saveComment(content, fan, "root")
val reply = saveComment(content, fan, "reply", root)
entityManager.flush()
val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}"
mockMvc.perform(delete(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
entityManager.flush()
entityManager.clear()
assertFalse(entityManager.find(AudioContentComment::class.java, root.id).isActive)
assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive)
mockMvc.perform(delete(path).with(adminAuthentication()))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data").value(nullValue()))
entityManager.flush()
entityManager.clear()
assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive)
}
@Test
@DisplayName("잘못된 요청은 공통 400 envelope으로 변환한다")
fun shouldRejectInvalidCommentRequests() {
val character = createCharacter("comment-request")
val content = saveAudioContent(character.creatorMember!!, "comment-request-content")
entityManager.flush()
listOf("{}", "{\"comment\":", "{\"comment\":\"comment\",\"unknown\":true}").forEach { body ->
mockMvc.perform(
post(commentsPath(character.id!!, content.id!!))
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
}
mockMvc.perform(
get(commentsPath(character.id!!, content.id!!))
.param("page", "-1")
.param("size", "0")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
mockMvc.perform(
get(commentsPath(character.id!!, content.id!!))
.param("timezone", "UTC")
.param("page", "0")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
}
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-$name-character",
name = name,
description = "description",
systemPrompt = "prompt"
).also {
it.creatorMember!!.profileImage = "profile/$name.png"
}
private fun saveAudioContent(owner: Member, title: String): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
entityManager.persist(theme)
return AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
price = 100,
isCommentAvailable = true
).apply {
member = owner
this.theme = theme
isActive = true
entityManager.persist(this)
}
}
private fun saveComment(
content: AudioContent,
writer: Member,
comment: String,
parent: AudioContentComment? = null
): AudioContentComment {
return AudioContentComment(comment = comment, languageCode = "ko").apply {
audioContent = content
member = writer
this.parent = parent
entityManager.persist(this)
}
}
private fun saveMember(nickname: String): Member {
return Member(
email = "$nickname@example.com",
password = "password",
nickname = nickname,
role = MemberRole.USER
).apply(entityManager::persist)
}
private fun commentsFor(contentId: Long): List<AudioContentComment> {
entityManager.flush()
return entityManager.createQuery(
"select comment from AudioContentComment comment where comment.audioContent.id = :contentId",
AudioContentComment::class.java
).setParameter("contentId", contentId).resultList
}
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
private fun commentsPath(characterId: Long, contentId: Long): String {
return "/api/v2/admin/ai-characters/$characterId/audio-contents/$contentId/comments"
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
}
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -46,6 +47,7 @@ import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
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 org.springframework.transaction.annotation.Transactional
@@ -80,6 +82,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
@BeforeEach
fun replaceActualServicePublishers() {
registerMysqlDateFunctions()
originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher)
originalCreatorAdminContentServicePublisher = replacePublisher(
creatorAdminContentService,
@@ -94,8 +97,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
}
@Test
@DisplayName("생성은 themeId, 예약일, seriesIds를 포함한 multipart 요청으로 target 소유 콘텐츠 상세를 반환한다")
fun shouldCreateOwnedContentWithThemeIdReleaseDateAndSeriesIds() {
@DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다")
fun shouldCreateOwnedContentWithUtcMultipartRequest() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-character",
name = "v2-audio-create-character",
@@ -104,7 +107,6 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
)
val theme = AudioContentTheme(theme = "create-theme", image = "theme.png")
entityManager.persist(theme)
val series = saveSeries("create-series", character.creatorMember!!)
entityManager.flush()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://s3.example.com/test"))
@@ -112,14 +114,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"created audio","description":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"isAdult":false,"isActive":true,"themeId":${theme.id},"releaseDateUtc":"2026-07-25T00:00:00Z","seriesIds":[${series.id}],"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"}
{"title":"created audio","detail":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"releaseDate":"2027-07-30T10:00:00Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"}
""".trimIndent().toByteArray()
)
)
@@ -127,28 +129,13 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.title").value("created audio"))
.andExpect(jsonPath("$.data.description").value("created detail"))
.andExpect(jsonPath("$.data.languageCode").value("en"))
.andExpect(jsonPath("$.data.tag").value("#calm #night"))
.andExpect(jsonPath("$.data.price").value(100))
.andExpect(jsonPath("$.data.totalContentCount").value(7))
.andExpect(jsonPath("$.data.remainingContentCount").value(7))
.andExpect(jsonPath("$.data.isActivePreview").value(true))
.andExpect(jsonPath("$.data.isOnlyRental").value(true))
.andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY"))
.andExpect(jsonPath("$.data.isActive").value(false))
.andExpect(jsonPath("$.data.isPointAvailable").value(true))
.andExpect(jsonPath("$.data.isCommentAvailable").value(false))
.andExpect(jsonPath("$.data.isAvailableUsePoint").value(true))
.andExpect(jsonPath("$.data.releaseDateUtc").value("2026-07-25T00:00:00Z"))
.andExpect(jsonPath("$.data.seriesIds[0]").value(series.id))
.andExpect(jsonPath("$.data.content").doesNotExist())
.andExpect(jsonPath("$.data.contentId").isNumber)
.andExpect(jsonPath("$.data.title").doesNotExist())
}
@Test
@DisplayName("수정은 seriesIds를 target 소유 시리즈로 교체하고 상세를 반환한다")
fun shouldReplaceOwnedContentSeriesIds() {
@DisplayName("수정은 text/plain request part를 415와 부작용 없음으로 거부한다")
fun shouldRejectTextPlainRequestPartBeforeSideEffects() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-series-update-character",
name = "v2-audio-series-update-character",
@@ -156,7 +143,6 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
systemPrompt = "prompt"
)
val oldSeries = saveSeries("old-series", character.creatorMember!!)
val newSeries = saveSeries("new-series", character.creatorMember!!)
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "series update title",
@@ -164,6 +150,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
)
saveSeriesContent(oldSeries, content)
entityManager.flush()
val beforeAudioContents = countAudioContents()
val beforeSeriesContents = countSeriesContents()
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
@@ -172,15 +160,19 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
"""{"seriesIds":[${newSeries.id}]}""".toByteArray()
"""{"title":"series update title","detail":"updated detail"}""".toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.seriesIds.length()").value(1))
.andExpect(jsonPath("$.data.seriesIds[0]").value(newSeries.id))
.andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(false))
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
assertNoSideEffects(beforeAudioContents, beforeSeriesContents) {
assertEquals("series update title", entityManager.find(AudioContent::class.java, content.id).title)
assertEquals(oldSeries.id, linkedSeriesId(content.id!!))
}
}
@ParameterizedTest
@@ -189,8 +181,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("생성은 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다")
fun shouldRejectCreateWithOtherCharacterSeriesIdsBeforeUpload(language: String, message: String) {
@DisplayName("생성은 계약에 없는 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다")
fun shouldRejectCreateWithUnsupportedSeriesIdsBeforeUpload(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-series-target",
name = "v2-audio-create-series-target",
@@ -213,14 +205,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"invalid series audio","description":"created detail","price":100,"isAdult":false,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]}
{"title":"invalid series audio","detail":"created detail","tags":"","price":100,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]}
""".trimIndent().toByteArray()
)
)
@@ -233,8 +225,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
}
@Test
@DisplayName("생성은 처리 완료 전 유지할 수 없는 isActive false를 거부한다")
fun shouldRejectCreateWithInactiveRequest() {
@DisplayName("생성은 레거시 계약에 없는 isActive 필드를 거부한다")
fun shouldRejectCreateWithUnsupportedIsActiveField() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-inactive-character",
name = "v2-audio-create-inactive-character",
@@ -248,14 +240,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"inactive audio","description":"created detail","price":100,"isAdult":false,"isActive":false,"themeId":${theme.id},"seriesIds":[]}
{"title":"inactive audio","detail":"created detail","tags":"","price":100,"isActive":false,"themeId":${theme.id}}
""".trimIndent().toByteArray()
)
)
@@ -283,14 +275,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"invalid price audio","description":"created detail","price":1,"isAdult":false,"themeId":${theme.id},"seriesIds":[]}
{"title":"invalid price audio","detail":"created detail","tags":"","price":1,"themeId":${theme.id}}
""".trimIndent().toByteArray()
)
)
@@ -323,7 +315,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"isActive":false,"releaseDateUtc":"2026-07-25T00:00:00Z"}""".toByteArray()
)
)
@@ -336,8 +328,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
}
@Test
@DisplayName("AI 캐릭터 콘텐츠 목록은 target 소유 공개 콘텐츠만 signed URL과 보정된 페이지로 반환한다")
fun shouldListTargetContentsWithSignedUrlAndNormalizedPagination() {
@DisplayName("AI 캐릭터 콘텐츠 목록은 search_word와 레거시 signed URL item을 반환한다")
fun shouldListTargetContentsWithLegacySearchAndSignedUrl() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-list-character",
name = "v2-audio-list-character",
@@ -372,25 +364,22 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("search", "night")
.param("status", "OPEN")
.param("page", "-1")
.param("search_word", "target night")
.param("page", "0")
.param("size", "100")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].contentId").value(content.id))
.andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id))
.andExpect(jsonPath("$.data.items[0].title").value("target night walk"))
.andExpect(
jsonPath("$.data.items[0].audioSignedUrl").value(
jsonPath("$.data.items[0].contentUrl").value(
"https://signed.example.com/private/target-night-walk.mp3?Expires=1"
)
)
.andExpect(jsonPath("$.data.items[0].content").doesNotExist())
.andExpect(jsonPath("$.data.page").doesNotExist())
Mockito.verify(audioContentCloudFront).generateSignedURL(
"private/target-night-walk.mp3",
@@ -431,8 +420,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
}
@Test
@DisplayName("status SCHEDULED 목록은 target 소유 예약 콘텐츠만 반환한다")
fun shouldListScheduledContentsWhenStatusIsScheduled() {
@DisplayName("search_word 목록은 target 소유 예약 콘텐츠도 검색한다")
fun shouldSearchScheduledOwnedContents() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-list-scheduled-status-character",
name = "v2-audio-list-scheduled-status-character",
@@ -456,18 +445,17 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("status", "SCHEDULED")
.param("search_word", "scheduled explicit")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].contentId").value(scheduled.id))
.andExpect(jsonPath("$.data.items[0].status").value("SCHEDULED"))
.andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id))
}
@Test
@DisplayName("status SCHEDULED 목록은 UTC 기준 미래 예약 콘텐츠를 서버 시간대와 무관하게 반환한다")
fun shouldListUtcFutureScheduledContentsWhenServerTimezoneIsAheadOfUtc() {
@DisplayName("목록은 UTC 미래 예약 콘텐츠의 레거시 releaseDate를 반환한다")
fun shouldReturnLegacyReleaseDateForUtcFutureScheduledContent() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-list-utc-scheduled-character",
name = "v2-audio-list-utc-scheduled-character",
@@ -486,13 +474,13 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("status", "SCHEDULED")
.param("search_word", "utc future")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].contentId").value(scheduled.id))
.andExpect(jsonPath("$.data.items[0].status").value("SCHEDULED"))
.andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id))
.andExpect(jsonPath("$.data.items[0].releaseDate").isString)
}
@Test
@@ -520,13 +508,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.contentId").value(content.id))
.andExpect(jsonPath("$.data.description").value("detail"))
.andExpect(jsonPath("$.data.detail").value("detail"))
.andExpect(jsonPath("$.data.languageCode").value("ko"))
.andExpect(jsonPath("$.data.themeStr").value("theme-detail night walk"))
.andExpect(jsonPath("$.data.tag").value(""))
.andExpect(jsonPath("$.data.duration").value("01:10:00"))
.andExpect(jsonPath("$.data.releaseDate").doesNotExist())
.andExpect(jsonPath("$.data.releaseDate").value(nullValue()))
.andExpect(jsonPath("$.data.totalContentCount").doesNotExist())
.andExpect(jsonPath("$.data.remainingContentCount").doesNotExist())
.andExpect(jsonPath("$.data.orderSequence").doesNotExist())
@@ -539,6 +526,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.remainingTime").doesNotExist())
.andExpect(jsonPath("$.data.creatorOtherContentList").isEmpty)
.andExpect(jsonPath("$.data.sameThemeOtherContentList").isEmpty)
.andExpect(jsonPath("$.data.isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.isLike").value(false))
.andExpect(jsonPath("$.data.likeCount").value(0))
.andExpect(jsonPath("$.data.commentList").isEmpty)
@@ -551,17 +539,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.buyerList").isEmpty)
.andExpect(jsonPath("$.data.isAvailableUsePoint").value(true))
.andExpect(jsonPath("$.data.translated").doesNotExist())
.andExpect(jsonPath("$.data.seriesIds").isEmpty)
.andExpect(
jsonPath("$.data.audioSignedUrl").value(
"https://signed.example.com/private/detail-night-walk.mp3?Expires=1"
)
)
.andExpect(
jsonPath("$.data.contentUrl").value(
"https://signed.example.com/private/detail-night-walk.mp3?Expires=1"
)
)
.andExpect(jsonPath("$.data.audioSignedUrl").doesNotExist())
.andExpect(jsonPath("$.data.content").doesNotExist())
Mockito.verify(audioContentCloudFront).generateSignedURL(
@@ -646,7 +629,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"other after title"}""".toByteArray()
)
)
@@ -699,7 +682,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"seriesIds":[${otherSeries.id}]}""".toByteArray()
)
)
@@ -714,19 +697,20 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
}
}
@Test
@DisplayName("수정은 audioFile 교체를 거부한다")
fun shouldRejectAudioFileUpdate() {
@ParameterizedTest
@ValueSource(strings = ["audioFile", "contentFile"])
@DisplayName("수정은 audioFile과 contentFile 교체를 거부한다")
fun shouldRejectUnsupportedContentFileUpdate(filePart: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-audio-file-character",
name = "v2-audio-update-audio-file-character",
characterUUID = "v2-audio-update-$filePart-character",
name = "v2-audio-update-$filePart-character",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "audio file update title",
contentPath = "private/audio-file-update.mp3"
title = "$filePart update title",
contentPath = "private/$filePart-update.mp3"
)
entityManager.flush()
val beforeAudioContents = countAudioContents()
@@ -734,12 +718,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(MockMultipartFile(filePart, "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"{}".toByteArray()
)
)
@@ -749,7 +733,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.success").value(false))
assertNoSideEffects(beforeAudioContents, beforeSeriesContents) {
assertEquals("audio file update title", entityManager.find(AudioContent::class.java, content.id).title)
assertEquals("$filePart update title", entityManager.find(AudioContent::class.java, content.id).title)
}
}
@@ -784,7 +768,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"releaseDateUtc":"not-a-date"}""".toByteArray()
)
)
@@ -801,8 +785,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
}
@Test
@DisplayName("수정은 활성 콘텐츠를 미래 예약으로 변경할 때 비활성화해 조기 공개를 막는다")
fun shouldDeactivateContentWhenUpdatingFutureReleaseDateUtc() {
@DisplayName("수정은 레거시 계약에 없는 releaseDateUtc를 거부한다")
fun shouldRejectUnsupportedFutureReleaseDateUtc() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-future-release-character",
name = "v2-audio-update-future-release-character",
@@ -825,22 +809,21 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"releaseDateUtc":"$releaseDateUtc"}""".toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.isActive").value(false))
.andExpect(jsonPath("$.data.status").value("SCHEDULED"))
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertEquals(false, entityManager.find(AudioContent::class.java, content.id).isActive)
assertEquals(true, entityManager.find(AudioContent::class.java, content.id).isActive)
}
@Test
@DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 상세를 반환한다")
fun shouldSoftDeleteOwnedContentAndReturnDetail() {
@DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 data null을 반환한다")
fun shouldSoftDeleteOwnedContentAndReturnNull() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-character",
name = "v2-audio-update-character",
@@ -860,7 +843,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"after title","isActive":false}""".toByteArray()
)
)
@@ -868,9 +851,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.title").value("after title"))
.andExpect(jsonPath("$.data.isActive").value(false))
.andExpect(jsonPath("$.data.releaseDateUtc").doesNotExist())
.andExpect(jsonPath("$.data").value(nullValue()))
val updated = entityManager.find(AudioContent::class.java, content.id)
assertEquals("after title", updated.title)
assertEquals(false, updated.isActive)
assertEquals(null, updated.releaseDate)
}
@Test
@@ -895,8 +881,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.page").doesNotExist())
.andExpect(jsonPath("$.data.size").doesNotExist())
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
@@ -904,7 +890,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.size").doesNotExist())
}
private fun saveAudioContent(
@@ -1013,6 +999,15 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.toLong()
}
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.PutObjectRequest
import com.amazonaws.services.s3.model.PutObjectResult
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentService
@@ -15,11 +16,14 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
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.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -29,6 +33,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.mock.web.MockPart
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
@@ -38,6 +43,7 @@ import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.RequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Propagation
@@ -55,6 +61,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val audioContentService: AudioContentService,
private val objectMapper: ObjectMapper,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager
) {
@@ -94,7 +101,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertMissingPartRequest(
requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("audioFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication()),
@@ -107,9 +114,9 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
"coverImage,ko,잘못된 요청입니다.",
"coverImage,en,Invalid request.",
"coverImage,ja,無効なリクエストです。",
"audioFile,ko,잘못된 요청입니다.",
"audioFile,en,Invalid request.",
"audioFile,ja,無効なリクエストです。"
"contentFile,ko,잘못된 요청입니다.",
"contentFile,en,Invalid request.",
"contentFile,ja,無効なリクエストです。"
)
@DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다")
fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) {
@@ -121,12 +128,12 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
)
val theme = saveTheme()
val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1)
val audioBytes = if (filePart == "audioFile") byteArrayOf() else byteArrayOf(1)
val contentBytes = if (filePart == "contentFile") byteArrayOf() else byteArrayOf(1)
mockMvc.perform(
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(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", contentBytes))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
@@ -147,8 +154,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("생성은 audioFile part 누락을 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingAudioFileBeforeUpload(language: String, message: String) {
@DisplayName("생성은 contentFile part 누락을 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingContentFileBeforeUpload(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-missing-audio-character",
name = "v2-audio-create-missing-audio-character",
@@ -185,13 +192,132 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
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)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication()),
message = message
)
}
@ParameterizedTest
@CsvSource(
value = [
"text/plain,ko,잘못된 요청입니다.",
"text/plain,en,Invalid request.",
"text/plain,ja,無効なリクエストです。",
"<missing>,ko,잘못된 요청입니다.",
"<missing>,en,Invalid request.",
"<missing>,ja,無効なリクエストです。"
],
nullValues = ["<missing>"]
)
@DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
requestContentType: String?,
language: String,
message: String
) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language",
name = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!, requestContentType))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("생성은 미정의 multipart part를 업로드 전 invalid request로 거부한다")
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-undefined-part-$language",
name = "v2-audio-create-undefined-part-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("생성은 filename 없는 미정의 multipart part를 업로드 전 invalid request로 거부한다")
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-filename-less-part-$language",
name = "v2-audio-create-filename-less-part-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.part(MockPart("unexpected", "unexpected".toByteArray()))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@Test
@DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingThemeBeforeUpload() {
@@ -205,7 +331,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(999999L))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication())
@@ -219,6 +345,166 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertEquals(0L, countAudioContents())
}
@ParameterizedTest
@CsvSource(
value = [
"onlyStart|ko|미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.",
"onlyStart|en|You must enter both preview start and end times, or neither.",
"onlyStart|ja|プレビューの開始時間と終了時間は両方入力するか、両方入力しないでください。",
"malformed|ko|미리 듣기 시간 형식은 00:30:00 과 같아야 합니다",
"malformed|en|Preview time format must be like 00:30:00.",
"malformed|ja|プレビュー時間の形式は00:30:00のようにする必要があります。",
"tooShort|ko|미리 듣기의 최소 시간은 15초 입니다.",
"tooShort|en|The minimum preview time is 15 seconds.",
"tooShort|ja|プレビューの最小時間は15秒です。"
],
delimiter = '|'
)
@DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다")
fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-preview-$previewCase-$language",
name = "v2-audio-create-preview-$previewCase-$language",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
val previewFields = when (previewCase) {
"onlyStart" -> ",\"previewStartTime\":\"00:00:01\""
"malformed" -> ",\"previewStartTime\":\"00:1:01\",\"previewEndTime\":\"00:00:20\""
else -> ",\"previewStartTime\":\"00:00:01\",\"previewEndTime\":\"00:00:10\""
}
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id}$previewFields}
""".trimIndent().toByteArray()
)
)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
@Test
@DisplayName("생성은 timezone request field를 업로드 전 invalid request로 거부한다")
fun shouldRejectTimezoneRequestFieldBeforeUpload() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-timezone-field-character",
name = "v2-audio-create-timezone-field-character",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"2027-07-30 10:00","timezone":"UTC"}
"""
)
}
@ParameterizedTest
@ValueSource(strings = ["2027-07-30 10:00", "2027-07-30T10:00:00+09:00", "2027-07-30T10:00:00+00:00"])
@DisplayName("생성은 local 또는 offset releaseDate를 업로드 전 invalid request로 거부한다")
fun shouldRejectNonUtcReleaseDateBeforeUpload(releaseDate: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}",
name = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"$releaseDate"}
"""
)
}
@ParameterizedTest
@ValueSource(strings = ["missing", "null"])
@DisplayName("생성은 price 누락과 null을 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingOrNullPriceBeforeUpload(priceCase: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-$priceCase-price-character",
name = "v2-audio-create-$priceCase-price-character",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
val priceJson = if (priceCase == "null") ",\"price\":null" else ""
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"","themeId":${theme.id}$priceJson}
"""
)
}
@ParameterizedTest
@ValueSource(
strings = [
"themeId",
"isAdult",
"isGeneratePreview",
"isOnlyRental",
"isPointAvailable",
"isCommentAvailable",
"isFullDetailVisible"
]
)
@DisplayName("생성은 non-null primitive null을 업로드 전 invalid request로 거부한다")
fun shouldRejectNullPrimitiveFieldsBeforeUpload(field: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-null-$field-character",
name = "v2-audio-create-null-$field-character",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
val primitiveFields = listOf(
"price" to "100",
"themeId" to theme.id.toString(),
"isAdult" to "false",
"isGeneratePreview" to "false",
"isOnlyRental" to "false",
"isPointAvailable" to "false",
"isCommentAvailable" to "false",
"isFullDetailVisible" to "true"
).joinToString(",") { (key, value) ->
val fieldValue = if (key == field) "null" else value
"\"$key\":$fieldValue"
}
assertInvalidCreateRequestBeforeSideEffects(
characterId = character.id!!,
request = """
{"title":"audio","detail":"detail","tags":"",$primitiveFields}
"""
)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다")
@@ -237,7 +523,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication())
@@ -271,7 +557,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication())
@@ -307,7 +593,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(requestPart(theme.id!!))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(adminAuthentication())
@@ -325,8 +611,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
}
@Test
@DisplayName("생성은 tags 누락과 isActive true를 legacy pipeline 기본 계약으로 허용한다")
fun shouldAllowMissingTagsAndActiveTrueRequest() {
@DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다")
fun shouldCreateWithUtcReleaseDateAndReturnContentId() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-minimal-character",
name = "v2-audio-create-minimal-character",
@@ -337,26 +623,74 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
val response = 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("contentFile", "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}
{"title":"utc audio","detail":"utc detail","tags":"#utc","price":100,"purchaseOption":"BOTH","limited":7,"releaseDate":"2027-07-30T10:00:01Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":false,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"ko"}
""".trimIndent().toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.title").value("minimal audio"))
.andExpect(jsonPath("$.data.tag").value(""))
.andExpect(jsonPath("$.data.isActive").value(false))
.andExpect(jsonPath("$.data.contentId").isNumber)
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
assertEquals(setOf("contentId"), data.fieldNames().asSequence().toSet())
val content = findAudioContentByTitle("utc audio")!!
assertEquals(data.path("contentId").asLong(), content.id)
assertEquals("utc detail", content.detail)
assertEquals(7, content.limited)
assertEquals("2027-07-30T10:00:01", content.releaseDate.toString())
val uploadCaptor = ArgumentCaptor.forClass(PutObjectRequest::class.java)
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(uploadCaptor.capture())
val audioUpload = uploadCaptor.allValues.single { it.key.startsWith("input/") }
assertEquals("00:00:05", audioUpload.metadata.userMetadata["preview_start_time"])
assertEquals("00:00:25", audioUpload.metadata.userMetadata["preview_end_time"])
assertTrue(audioUpload.metadata.userMetadata["generate_preview"].toBoolean())
}
@ParameterizedTest
@ValueSource(strings = ["", ",\"releaseDate\":null"])
@DisplayName("생성은 생략 또는 null releaseDate를 허용한다")
fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}",
name = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}",
description = "description",
systemPrompt = "prompt"
)
val theme = saveTheme()
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""
{"title":"nullable release","detail":"detail","tags":"","price":100,"themeId":${theme.id}$releaseDateField}
""".trimIndent().toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.contentId").isNumber)
}
private fun saveTheme(): AudioContentTheme {
@@ -366,13 +700,16 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
return theme
}
private fun requestPart(themeId: Long): MockMultipartFile {
private fun requestPart(
themeId: Long,
contentType: String? = MediaType.APPLICATION_JSON_VALUE
): MockMultipartFile {
return MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
contentType,
"""
{"title":"audio","description":"detail","price":100,"themeId":$themeId,"isActive":true}
{"title":"audio","detail":"detail","tags":"","price":100,"themeId":$themeId}
""".trimIndent().toByteArray()
)
}
@@ -394,6 +731,30 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
assertEquals(0L, countAudioContents())
}
private fun assertInvalidCreateRequestBeforeSideEffects(characterId: Long, request: String) {
mockMvc.perform(
multipart("/api/v2/admin/ai-characters/$characterId/audio-contents")
.file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)))
.file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
request.trimIndent().toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
assertActualAudioContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
assertEquals(0L, countAudioContents())
}
private fun countAudioContents(): Long {
return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java)
.singleResult
@@ -133,7 +133,7 @@ class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor(
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("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
@@ -205,7 +205,7 @@ class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor(
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()))
.file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
@@ -1,15 +1,18 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse
import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.content.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.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
@@ -36,14 +39,85 @@ import javax.persistence.EntityManager
class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager
) {
@MockBean
private lateinit var audioContentCloudFront: AudioContentCloudFront
@Test
@DisplayName("상세는 과거 공개일을 releaseDateUtc에만 반환하고 legacy releaseDate는 비운다")
fun shouldExposePastReleaseDateOnlyAsUtcField() {
@DisplayName("목록은 search_word로 검색하고 레거시 전체 item exact 필드를 반환한다")
fun shouldSearchByLegacyQueryAndReturnFullLegacyListItem() {
registerMysqlDateFunctions()
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-list-character",
name = "v2-audio-query-list-character",
description = "description",
systemPrompt = "prompt"
)
val matching = saveAudioContent(
owner = character.creatorMember!!,
title = "legacy target audio",
releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0)
)
saveAudioContent(
owner = character.creatorMember!!,
title = "different audio",
releaseDate = LocalDateTime.of(2026, 7, 24, 0, 0)
)
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" }
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents")
.param("search_word", "legacy target")
.param("page", "0")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.totalCount").value(1))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
val item = data.path("items").path(0)
assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet())
assertEquals(
setOf(
"audioContentId",
"title",
"detail",
"coverImageUrl",
"creatorNickname",
"theme",
"price",
"totalContentCount",
"remainingContentCount",
"isAdult",
"isPointAvailable",
"isCommentAvailable",
"remainingTime",
"contentUrl",
"date",
"releaseDate",
"tags"
),
item.fieldNames().asSequence().toSet()
)
assertEquals(matching.id, item.path("audioContentId").asLong())
assertEquals("legacy target audio", item.path("title").asText())
assertEquals("detail", item.path("detail").asText())
assertEquals("https://test.cloudfront.net/cover/legacy target audio.png", item.path("coverImageUrl").asText())
assertEquals(
"https://signed.example.com/private/legacy target audio.mp3?Expires=1",
item.path("contentUrl").asText()
)
}
@Test
@DisplayName("상세는 timezone 없이 기존 nested DTO exact 필드를 반환한다")
fun shouldReturnFullLegacyDetailContractWithoutTimezone() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-release-character",
name = "v2-audio-query-release-character",
@@ -59,42 +133,150 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://signed.example.com/query-release.mp3?Expires=1")
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/query-release.mp3?Expires=1"))
.andExpect(jsonPath("$.data.isOnlyRental").value(true))
.andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY"))
.andReturn()
val data = objectMapper.readTree(response.response.contentAsString).path("data")
assertEquals(
setOf(
"contentId",
"title",
"detail",
"languageCode",
"coverImageUrl",
"contentUrl",
"themeStr",
"tag",
"price",
"duration",
"releaseDate",
"totalContentCount",
"remainingContentCount",
"orderSequence",
"isActivePreview",
"isAdult",
"isMosaic",
"isOnlyRental",
"existOrdered",
"purchaseOption",
"orderType",
"remainingTime",
"creatorOtherContentList",
"sameThemeOtherContentList",
"isCommentAvailable",
"isLike",
"likeCount",
"commentList",
"commentCount",
"isPin",
"isAvailablePin",
"creator",
"previousContent",
"nextContent",
"buyerList",
"isAvailableUsePoint",
"translated"
),
data.fieldNames().asSequence().toSet()
)
assertEquals(
setOf("creatorId", "nickname", "profileImageUrl", "isFollowing", "isFollow", "isNotify"),
data.path("creator").fieldNames().asSequence().toSet()
)
Mockito.verify(audioContentCloudFront).generateSignedURL("private/query release audio.mp3", 10_800_000L)
}
@Test
@DisplayName("상세는 미래 예약일을 UTC Z releaseDate로 반환한다")
fun shouldReturnFutureReleaseDateAsUtcIso() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-future-release",
name = "v2-audio-query-future-release",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "future release audio",
releaseDate = LocalDateTime.of(2027, 7, 30, 10, 0)
)
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://signed.example.com/future-release.mp3?Expires=1")
mockMvc.perform(
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"))
.andExpect(jsonPath("$.data.releaseDate").value("2027-07-30T10:00:00Z"))
.andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/future-release.mp3?Expires=1"))
}
@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"
@DisplayName("상세는 현재 또는 과거 예약일의 releaseDate를 null로 반환한다")
fun shouldReturnNullReleaseDateForPastOrCurrentReleaseDate() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-past-release-character",
name = "v2-audio-query-past-release-character",
description = "description",
systemPrompt = "prompt"
)
val nestedResponseFieldNames = setOf(
"creatorOtherContentList",
"sameThemeOtherContentList",
"commentList",
"creator",
"previousContent",
"nextContent",
"buyerList",
"translated"
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "past release audio",
releaseDate = LocalDateTime.of(2020, 1, 1, 0, 0)
)
val leakedTypes = AiCharacterAdminAudioContentResponse::class.java.declaredFields
.filter { it.name in nestedResponseFieldNames }
.map { it.type.packageName }
.filter { it in legacyPackages }
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://signed.example.com/past-release.mp3?Expires=1")
assertFalse(leakedTypes.isNotEmpty(), "legacy nested DTO packages leaked: $leakedTypes")
val response = mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/past-release.mp3?Expires=1"))
.andReturn()
assertTrue(objectMapper.readTree(response.response.contentAsString).path("data").path("releaseDate").isNull)
}
@Test
@DisplayName("상세는 timezone query를 허용하지 않는다")
fun shouldRejectTimezoneForDetail() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-query-timezone-character",
name = "v2-audio-query-timezone-character",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "timezone required audio",
releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0)
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.param("timezone", "UTC")
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
}
@Test
@DisplayName("상세 응답은 레거시 GetAudioContentDetailResponse 타입을 재사용한다")
fun shouldReuseLegacyDetailResponseType() {
assertEquals(GetAudioContentDetailResponse::class.java, AiCharacterAdminAudioContentResponse::class.java)
}
private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent {
@@ -104,6 +286,8 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
title = title,
detail = "detail",
languageCode = "ko",
limited = 10,
remaining = 5,
price = 100,
purchaseOption = PurchaseOption.RENT_ONLY,
isOnlyRental = false,
@@ -122,6 +306,15 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
}
}
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
@@ -1,10 +1,12 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.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.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
@@ -28,11 +30,12 @@ import javax.persistence.EntityManager
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager
) {
@Test
@DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 v2 필드명으로 반환한다")
fun shouldReturnActiveThemesWithV2FieldNames() {
@DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 레거시 exact 필드로 반환한다")
fun shouldReturnActiveThemesWithLegacyFields() {
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)
@@ -41,19 +44,20 @@ class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor(
entityManager.persist(inactive)
entityManager.flush()
mockMvc.perform(
val response = 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())
.andExpect(jsonPath("$.data[0].id").value(earlier.id))
.andExpect(jsonPath("$.data[0].theme").value("earlier theme"))
.andExpect(jsonPath("$.data[0].image").value("https://test.cloudfront.net/theme/earlier.png"))
.andReturn()
val item = objectMapper.readTree(response.response.contentAsString).path("data").path(0)
assertEquals(setOf("id", "theme", "image"), item.fieldNames().asSequence().toSet())
}
@Test
@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.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
@@ -21,15 +22,20 @@ 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.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
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.mock.web.MockPart
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
@@ -38,6 +44,7 @@ import org.springframework.test.util.AopTestUtils
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
@@ -77,9 +84,149 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher)
}
@ParameterizedTest
@CsvSource(
value = [
"text/plain,ko,잘못된 요청입니다.",
"text/plain,en,Invalid request.",
"text/plain,ja,無効なリクエストです。",
"<missing>,ko,잘못된 요청입니다.",
"<missing>,en,Invalid request.",
"<missing>,ja,無効なリクエストです。"
],
nullValues = ["<missing>"]
)
@DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
requestContentType: String?,
language: String,
message: String
) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language",
name = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "before update")
entityManager.flush()
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(
MockMultipartFile(
"request",
"request.json",
requestContentType,
"{\"title\":\"after update\"}".toByteArray()
)
)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isUnsupportedMediaType)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
entityManager.clear()
assertEquals("before update", entityManager.find(AudioContent::class.java, content.id).title)
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("수정은 미정의 multipart part를 mutation 전 invalid request로 거부한다")
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-undefined-part-$language",
name = "v2-audio-update-undefined-part-$language",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "undefined-part-before")
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://test.cloudfront.net/private/undefined-part-before.mp3")
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1)))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"should not change"}""".toByteArray()
)
)
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
entityManager.clear()
assertEquals("undefined-part-before", entityManager.find(AudioContent::class.java, content.id).title)
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("수정은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다")
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-filename-less-part-$language",
name = "v2-audio-update-filename-less-part-$language",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "filename-less-part-before")
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenReturn("https://test.cloudfront.net/private/filename-less-part-before.mp3")
mockMvc.perform(
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}")
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"should not change"}""".toByteArray()
)
)
.part(MockPart("unexpected", "unexpected".toByteArray()))
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
entityManager.clear()
assertEquals("filename-less-part-before", entityManager.find(AudioContent::class.java, content.id).title)
assertActualCreatorAdminContentServicePublisher(applicationEventPublisher)
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@Test
@DisplayName("수정은 동일 seriesIds의 기존 연결 metadata를 보존한다")
fun shouldPreserveExistingSeriesContentMetadataForSameSeriesIds() {
@DisplayName("수정은 레거시 전체 request를 적용하고 기존 series metadata와 data null 계약을 보존한다")
fun shouldApplyLegacyRequestAndPreserveExistingSeriesMetadata() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-preserve-series-character",
name = "v2-audio-update-preserve-series-character",
@@ -103,24 +250,43 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
"""{"seriesIds":[${series.id}]}""".toByteArray()
MediaType.APPLICATION_JSON_VALUE,
"""
{
"title":"updated title",
"detail":"updated detail",
"tags":"#updated",
"price":250,
"isAdult":true,
"isActive":false,
"isPointAvailable":false,
"isCommentAvailable":false
}
""".trimIndent().toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.seriesIds[0]").value(series.id))
.andExpect(jsonPath("$.data").value(nullValue()))
val saved = findSeriesContents(content.id!!).single()
assertEquals(beforeId, saved.id)
assertEquals(7, saved.orders)
assertEquals(beforeCreatedAt, saved.createdAt)
val updated = entityManager.find(AudioContent::class.java, content.id)
assertEquals("updated title", updated.title)
assertEquals("updated detail", updated.detail)
assertEquals(250, updated.price)
assertEquals(true, updated.isAdult)
assertEquals(false, updated.isActive)
assertEquals(false, updated.isPointAvailable)
assertEquals(false, updated.isCommentAvailable)
}
@Test
@DisplayName("수정은 seriesIds 교집합 metadata를 보존하고 차집합만 추가·제거한다")
fun shouldOnlyInsertAndDeleteSeriesContentDifference() {
@DisplayName("레거시 수정은 기존 series 연결을 추가·제거하지 않는다")
fun shouldLeaveSeriesConnectionsUnchanged() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-diff-series-character",
name = "v2-audio-update-diff-series-character",
@@ -147,21 +313,22 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
"""{"seriesIds":[${keptSeries.id},${addedSeries.id}]}""".toByteArray()
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"diff updated title"}""".toByteArray()
)
)
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.seriesIds.length()").value(2))
.andExpect(jsonPath("$.data").value(nullValue()))
val saved = findSeriesContents(content.id!!)
assertEquals(setOf(keptSeries.id, addedSeries.id), saved.map { it.series!!.id }.toSet())
assertEquals(setOf(removedSeries.id, keptSeries.id), saved.map { it.series!!.id }.toSet())
val kept = saved.single { it.series!!.id == keptSeries.id }
assertEquals(keptId, kept.id)
assertEquals(5, kept.orders)
assertEquals(keptCreatedAt, kept.createdAt)
assertEquals(false, saved.any { it.series!!.id == addedSeries.id })
}
@Test
@@ -181,7 +348,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
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()))
.file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.with(adminAuthentication())
)
.andExpect(status().isOk)
@@ -193,7 +360,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
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()))
.file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.with(adminAuthentication())
)
.andExpect(status().isOk)
@@ -221,7 +388,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
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()))
.file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray()))
.with(adminAuthentication())
)
.andExpect(status().isOk)
@@ -232,27 +399,28 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
Mockito.verifyNoInteractions(applicationEventPublisher)
}
@Test
@DisplayName("수정은 빈 audioFile part도 교체 요청으로 거부한다")
fun shouldRejectEmptyAudioFileUpdate() {
@ParameterizedTest
@ValueSource(strings = ["audioFile", "contentFile"])
@DisplayName("수정은 파일 교체 part를 mutation 전 invalid request로 거부한다")
fun shouldRejectFileReplacementPartBeforeSideEffects(filePart: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-update-empty-audio-character",
name = "v2-audio-update-empty-audio-character",
characterUUID = "v2-audio-update-file-replacement-$filePart",
name = "v2-audio-update-file-replacement-$filePart",
description = "description",
systemPrompt = "prompt"
)
val content = saveAudioContent(character.creatorMember!!, "empty-audio-update-content")
val content = saveAudioContent(character.creatorMember!!, "file-replacement-$filePart")
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(filePart, "empty.mp3", "audio/mpeg", byteArrayOf()))
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"should not change"}""".toByteArray()
)
)
@@ -290,7 +458,7 @@ class AiCharacterAdminAudioContentUpdateTest @Autowired constructor(
MockMultipartFile(
"request",
"request.json",
MediaType.TEXT_PLAIN_VALUE,
MediaType.APPLICATION_JSON_VALUE,
"""{"title":"cover failure after title"}""".toByteArray()
)
)