feat(ai-character): 시리즈 관리자 API를 추가한다
This commit is contained in:
+424
@@ -0,0 +1,424 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
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.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
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.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.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.ResultActions
|
||||
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.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 AiCharacterAdminSeriesContentTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("연결 콘텐츠 목록은 legacy 응답 형태와 owner 전체 link 수를 반환한다")
|
||||
fun shouldReturnLinkedContentsWithLegacyResponseShape() {
|
||||
val character = createCharacter("series-content-list-character")
|
||||
val owner = character.creatorMember!!
|
||||
val genre = saveGenre("series-content-list-genre")
|
||||
val targetSeries = saveSeries(owner, genre, "target series")
|
||||
val anotherSeries = saveSeries(owner, genre, "another series")
|
||||
val targetContent = saveAudioContent(owner, "target night")
|
||||
val anotherContent = saveAudioContent(owner, "another night")
|
||||
saveSeriesContent(targetSeries, targetContent)
|
||||
saveSeriesContent(anotherSeries, anotherContent)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val result = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/${targetSeries.id}/contents")
|
||||
.param("page", "0")
|
||||
.param("size", "20")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data")
|
||||
val item = data.path("items").single()
|
||||
assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(2, data.path("totalCount").asInt())
|
||||
assertEquals(targetContent.id, item.path("contentId").asLong())
|
||||
assertEquals("target night", item.path("title").asText())
|
||||
assertEquals("https://test.cloudfront.net/audio/target-night.png", item.path("coverImage").asText())
|
||||
assertEquals(setOf("contentId", "coverImage", "title", "isAdult"), item.fieldNames().asSequence().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("미연결 콘텐츠 검색은 eligible owner 콘텐츠만 별도 배열 응답으로 반환한다")
|
||||
fun shouldSearchEligibleUnlinkedOwnerContentsWithSeparateResponse() {
|
||||
val character = createCharacter("series-content-search-character")
|
||||
val owner = character.creatorMember!!
|
||||
val otherCharacter = createCharacter("series-content-search-other-character")
|
||||
val genre = saveGenre("series-content-search-genre")
|
||||
val series = saveSeries(owner, genre, "search series")
|
||||
val linked = saveAudioContent(owner, "night linked")
|
||||
val eligible = saveAudioContent(owner, "night eligible")
|
||||
val reserved = saveAudioContent(owner, "night reserved", isActive = false)
|
||||
saveAudioContent(owner, "night processing", duration = null)
|
||||
saveAudioContent(owner, "night inactive", isActive = false, releaseDate = null)
|
||||
saveAudioContent(otherCharacter.creatorMember!!, "night foreign")
|
||||
saveSeriesContent(series, linked)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val result = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/search")
|
||||
.param("search_word", "night")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
|
||||
val items = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data")
|
||||
assertEquals(setOf(eligible.id, reserved.id), items.map { it.path("contentId").asLong() }.toSet())
|
||||
assertTrue(items.all { it.fieldNames().asSequence().toSet() == setOf("contentId", "title", "coverImage") })
|
||||
assertTrue(items.all { it.path("coverImage").asText().startsWith("https://test.cloudfront.net/audio/") })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("연결 목록은 pagination 경계를 적용하고 잘못된 pagination을 거부한다")
|
||||
fun shouldApplyPaginationAndRejectInvalidPagination() {
|
||||
val character = createCharacter("series-content-pagination-character")
|
||||
val owner = character.creatorMember!!
|
||||
val series = saveSeries(owner, saveGenre("series-content-pagination-genre"), "pagination series")
|
||||
saveSeriesContent(series, saveAudioContent(owner, "pagination first"))
|
||||
saveSeriesContent(series, saveAudioContent(owner, "pagination second"))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents")
|
||||
.param("page", "1")
|
||||
.param("size", "1")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(2))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||
|
||||
listOf("-1" to "20", "0" to "0").forEach { (page, size) ->
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents")
|
||||
.param("page", page)
|
||||
.param("size", size)
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("소유한 eligible 콘텐츠는 모두 연결하고 성공 응답은 data null이다")
|
||||
fun shouldLinkAllOwnedEligibleContentsAtomically() {
|
||||
val character = createCharacter("series-content-link-character")
|
||||
val owner = character.creatorMember!!
|
||||
val series = saveSeries(owner, saveGenre("series-content-link-genre"), "link series")
|
||||
val first = saveAudioContent(owner, "link first")
|
||||
val second = saveAudioContent(owner, "link second")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"contentIdList":[${first.id},${second.id}]}""")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(setOf(first.id, second.id), contentIdsInSeries(series.id!!).toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 연결 body에 계약 밖 field가 있으면 400으로 거부하고 연결하지 않는다")
|
||||
fun shouldRejectContentLinkUnknownFieldWithoutMutation() {
|
||||
val character = createCharacter("series-content-unknown-field-character")
|
||||
val owner = character.creatorMember!!
|
||||
val series = saveSeries(owner, saveGenre("series-content-unknown-field-genre"), "unknown field series")
|
||||
val content = saveAudioContent(owner, "unknown field content")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"contentIdList":[${content.id}],"unexpected":true}""")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertTrue(contentIdsInSeries(series.id!!).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("잘못된 콘텐츠 ID와 이미 연결된 콘텐츠는 일부 연결 없이 거부하고 빈 목록은 legacy 오류를 유지한다")
|
||||
fun shouldRejectInvalidOrLinkedContentBeforeAnyLinkMutation() {
|
||||
val character = createCharacter("series-content-invalid-link-character")
|
||||
val owner = character.creatorMember!!
|
||||
val otherCharacter = createCharacter("series-content-invalid-link-other-character")
|
||||
val series = saveSeries(owner, saveGenre("series-content-invalid-link-genre"), "invalid link series")
|
||||
val valid = saveAudioContent(owner, "valid link")
|
||||
val foreign = saveAudioContent(otherCharacter.creatorMember!!, "foreign link")
|
||||
val inactive = saveAudioContent(owner, "inactive link", isActive = false, releaseDate = null)
|
||||
val linked = saveAudioContent(owner, "already linked")
|
||||
saveSeriesContent(series, linked)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
listOf(valid.id, foreign.id),
|
||||
listOf(valid.id, Long.MAX_VALUE),
|
||||
listOf(valid.id, inactive.id),
|
||||
listOf(valid.id, valid.id),
|
||||
listOf(linked.id)
|
||||
).forEach { contentIdList ->
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"contentIdList":$contentIdList}""")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(setOf(linked.id), contentIdsInSeries(series.id!!).toSet())
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"contentIdList":[]}""")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.message").value("No content was added."))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("연결 해제는 기존 소유 link만 제거하고 성공 응답은 data null이다")
|
||||
fun shouldUnlinkExistingOwnerContentAndRejectMissingLink() {
|
||||
val character = createCharacter("series-content-unlink-character")
|
||||
val owner = character.creatorMember!!
|
||||
val series = saveSeries(owner, saveGenre("series-content-unlink-genre"), "unlink series")
|
||||
val content = saveAudioContent(owner, "unlink content")
|
||||
saveSeriesContent(series, content)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertTrue(contentIdsInSeries(series.id!!).isEmpty())
|
||||
|
||||
mockMvc.perform(
|
||||
delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("연결 해제는 soft delete된 기존 소유 link도 제거한다")
|
||||
fun shouldUnlinkSoftDeletedOwnerContent() {
|
||||
val character = createCharacter("series-content-unlink-soft-deleted-character")
|
||||
val owner = character.creatorMember!!
|
||||
val series = saveSeries(owner, saveGenre("series-content-unlink-soft-deleted-genre"), "soft deleted unlink")
|
||||
val content = saveAudioContent(owner, "soft deleted unlink content")
|
||||
saveSeriesContent(series, content)
|
||||
content.isActive = false
|
||||
content.releaseDate = null
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertTrue(contentIdsInSeries(series.id!!).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 또는 타 소유 시리즈와 malformed 연결 요청은 400으로 거부한다")
|
||||
fun shouldRejectInvalidSeriesAndMalformedContentRequests() {
|
||||
val character = createCharacter("series-content-invalid-series-character")
|
||||
val owner = character.creatorMember!!
|
||||
val otherCharacter = createCharacter("series-content-invalid-series-other-character")
|
||||
val genre = saveGenre("series-content-invalid-series-genre")
|
||||
val inactive = saveSeries(owner, genre, "inactive series", isActive = false)
|
||||
val foreign = saveSeries(otherCharacter.creatorMember!!, genre, "foreign series")
|
||||
val content = saveAudioContent(owner, "malformed content")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(inactive.id!!, foreign.id!!, Long.MAX_VALUE).forEach { seriesId ->
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/$seriesId/contents/search")
|
||||
.param("search_word", "content")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/ai-characters/${character.id}/series/${foreign.id}/contents")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
|
||||
mockMvc.perform(
|
||||
delete("/api/v2/admin/ai-characters/${character.id}/series/${foreign.id}/contents/${content.id}")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
}
|
||||
|
||||
private fun ResultActions.andExpectInvalidRequest(): ResultActions {
|
||||
return andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun saveGenre(name: String): SeriesGenre {
|
||||
return SeriesGenre(genre = name).also(entityManager::persist)
|
||||
}
|
||||
|
||||
private fun saveSeries(owner: Member, genre: SeriesGenre, title: String, isActive: Boolean = true): Series {
|
||||
return Series(
|
||||
title = title,
|
||||
introduction = "introduction for $title",
|
||||
publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON),
|
||||
isActive = isActive
|
||||
).apply {
|
||||
member = owner
|
||||
this.genre = genre
|
||||
coverImage = "series/${title.replace(" ", "-")}.png"
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAudioContent(
|
||||
owner: Member,
|
||||
title: String,
|
||||
isActive: Boolean = true,
|
||||
releaseDate: LocalDateTime? = LocalDateTime.of(2026, 7, 24, 10, 0),
|
||||
duration: String? = "00:10:00"
|
||||
): 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,
|
||||
releaseDate = releaseDate
|
||||
).apply {
|
||||
member = owner
|
||||
this.theme = theme
|
||||
this.isActive = isActive
|
||||
this.duration = duration
|
||||
content = "audio/${title.replace(" ", "-")}.mp3"
|
||||
coverImage = "audio/${title.replace(" ", "-")}.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 contentIdsInSeries(seriesId: Long): List<Long> {
|
||||
return entityManager.createQuery(
|
||||
"select seriesContent.content.id from SeriesContent seriesContent where seriesContent.series.id = :seriesId",
|
||||
java.lang.Long::class.java
|
||||
)
|
||||
.setParameter("seriesId", seriesId)
|
||||
.resultList
|
||||
.map(java.lang.Long::toLong)
|
||||
}
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
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.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.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.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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.net.URL
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminSeriesContractTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@MockBean
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
|
||||
@Autowired
|
||||
private lateinit var legacySeriesService: CreatorAdminContentSeriesService
|
||||
|
||||
private lateinit var originalPublisher: ApplicationEventPublisher
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(URL("https://s3.example.com/series-cover"))
|
||||
originalPublisher = replacePublisher(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
replacePublisher(originalPublisher)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("target invalid request는 400 invalid_request envelope을 유지한다")
|
||||
fun shouldReturnInvalidRequestEnvelopeForTarget(language: String, message: String) {
|
||||
val target = createCharacter("series-contract-target-$language")
|
||||
target.isActive = false
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}")
|
||||
).forEach { request ->
|
||||
mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication()))
|
||||
.andExpectInvalidRequest(message)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("series invalid request는 active target에서도 400 invalid_request envelope을 유지한다")
|
||||
fun shouldReturnInvalidRequestEnvelopeForSeries(language: String, message: String) {
|
||||
val target = createCharacter("series-contract-series-target-$language")
|
||||
val otherTarget = createCharacter("series-contract-series-other-$language")
|
||||
val genre = saveGenre("series-contract-series-genre-$language")
|
||||
val inactiveSeries = saveSeries(target.creatorMember!!, genre, "inactive detail", isActive = false)
|
||||
val foreignSeries = saveSeries(otherTarget.creatorMember!!, genre, "foreign detail")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${inactiveSeries.id}"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}")
|
||||
).forEach { request ->
|
||||
mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication()))
|
||||
.andExpectInvalidRequest(message)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("content/pagination invalid request는 400 invalid_request envelope을 유지한다")
|
||||
fun shouldReturnInvalidRequestEnvelopeForContentAndPagination(language: String, message: String) {
|
||||
val target = createCharacter("series-contract-content-target-$language")
|
||||
val otherTarget = createCharacter("series-contract-content-other-$language")
|
||||
val genre = saveGenre("series-contract-content-genre-$language")
|
||||
val series = saveSeries(target.creatorMember!!, genre, "content series")
|
||||
val inactiveSeries = saveSeries(target.creatorMember!!, genre, "inactive content series", isActive = false)
|
||||
val foreignSeries = saveSeries(otherTarget.creatorMember!!, genre, "foreign content series")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}/contents/search")
|
||||
.param("search_word", "night"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${series.id}/contents")
|
||||
.param("page", "-1")
|
||||
.param("size", "20"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${series.id}/contents")
|
||||
.param("page", "0")
|
||||
.param("size", "0"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${inactiveSeries.id}/contents/search")
|
||||
.param("search_word", "night"),
|
||||
get("/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}/contents/search")
|
||||
.param("search_word", "night")
|
||||
).forEach { request ->
|
||||
mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication()))
|
||||
.andExpectInvalidRequest(message)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("order/binding invalid request는 400 invalid_request envelope을 유지한다")
|
||||
fun shouldReturnInvalidRequestEnvelopeForOrderAndBinding(language: String, message: String) {
|
||||
val target = createCharacter("series-contract-order-target-$language")
|
||||
val otherTarget = createCharacter("series-contract-order-other-$language")
|
||||
val genre = saveGenre("series-contract-order-genre-$language")
|
||||
val owned = saveSeries(target.creatorMember!!, genre, "owned order")
|
||||
val inactive = saveSeries(target.creatorMember!!, genre, "inactive order", isActive = false)
|
||||
val foreign = saveSeries(otherTarget.creatorMember!!, genre, "foreign order")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
put("/api/v2/admin/ai-characters/${target.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{"),
|
||||
put("/api/v2/admin/ai-characters/${target.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"ids":[${owned.id},${foreign.id},${inactive.id}]}""")
|
||||
).forEach { request ->
|
||||
mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication()))
|
||||
.andExpectInvalidRequest(message)
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
delete("/api/v2/admin/ai-characters/${target.id}/series/${foreign.id}/contents/${owned.id}")
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest(message)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("타 owner 수정은 400 invalid_request로 막고 DB와 event side effect를 남기지 않는다")
|
||||
fun shouldRejectCrossOwnerMutationWithoutSideEffects() {
|
||||
val target = createCharacter("series-contract-mutation-target")
|
||||
val foreign = createCharacter("series-contract-mutation-foreign")
|
||||
val foreignSeries = saveSeries(foreign.creatorMember!!, saveGenre("series-contract-mutation-genre"), "foreign mutation")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
multipart(
|
||||
HttpMethod.PUT,
|
||||
"/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}"
|
||||
)
|
||||
.file(MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1)))
|
||||
.file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{".toByteArray()))
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest("Invalid request.")
|
||||
|
||||
entityManager.clear()
|
||||
assertEquals("foreign mutation", entityManager.find(Series::class.java, foreignSeries.id).title)
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any())
|
||||
}
|
||||
|
||||
private fun ResultActions.andExpectInvalidRequest(message: String): ResultActions {
|
||||
return andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun saveGenre(name: String): SeriesGenre = SeriesGenre(genre = name).also(entityManager::persist)
|
||||
|
||||
private fun saveSeries(owner: Member, genre: SeriesGenre, title: String, isActive: Boolean = true): Series {
|
||||
return Series(
|
||||
title = title,
|
||||
introduction = "introduction for $title",
|
||||
state = SeriesState.PROCEEDING,
|
||||
writer = "writer",
|
||||
studio = "studio",
|
||||
publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON),
|
||||
isActive = isActive,
|
||||
orders = 1
|
||||
).apply {
|
||||
member = owner
|
||||
this.genre = genre
|
||||
coverImage = "series/${title.replace(" ", "-")}.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"))
|
||||
)
|
||||
)
|
||||
|
||||
private fun replacePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher {
|
||||
val target = AopTestUtils.getTargetObject<CreatorAdminContentSeriesService>(legacySeriesService)
|
||||
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
|
||||
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
|
||||
return original
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
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.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.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
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminSeriesGenreTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("시리즈 장르 목록은 활성 장르만 orders 오름차순 직접 배열로 반환한다")
|
||||
fun shouldReturnActiveGenresOrderedByOrders() {
|
||||
val second = saveGenre("second", isAdult = true, orders = 20)
|
||||
val first = saveGenre("first", isAdult = false, orders = 10)
|
||||
saveGenre("inactive", isAdult = false, isActive = false, orders = 1)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get(PATH).with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andExpect(jsonPath("$.data[0].id").value(first.id))
|
||||
.andExpect(jsonPath("$.data[0].genre").value("first"))
|
||||
.andExpect(jsonPath("$.data[0].isAdult").value(false))
|
||||
.andExpect(jsonPath("$.data[1].id").value(second.id))
|
||||
.andExpect(jsonPath("$.data[1].genre").value("second"))
|
||||
.andExpect(jsonPath("$.data[1].isAdult").value(true))
|
||||
.andExpect(jsonPath("$.data[2]").doesNotExist())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("시리즈 장르 목록은 빈 목록과 ADMIN 공통 경계를 유지한다")
|
||||
fun shouldReturnEmptyListAndKeepAdminBoundary() {
|
||||
mockMvc.perform(get(PATH).with(adminAuthentication()))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.length()").value(0))
|
||||
|
||||
mockMvc.perform(get(PATH))
|
||||
.andExpect(status().isUnauthorized)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
private fun saveGenre(
|
||||
name: String,
|
||||
isAdult: Boolean,
|
||||
isActive: Boolean = true,
|
||||
orders: Int
|
||||
): SeriesGenre {
|
||||
return SeriesGenre(
|
||||
genre = name,
|
||||
isAdult = isAdult,
|
||||
isActive = isActive,
|
||||
orders = orders
|
||||
).also(entityManager::persist)
|
||||
}
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PATH = "/api/v2/admin/ai-characters/series-genres"
|
||||
}
|
||||
}
|
||||
+1156
File diff suppressed because it is too large
Load Diff
+254
@@ -0,0 +1,254 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
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.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.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.ResultActions
|
||||
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 javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminSeriesOrderTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager,
|
||||
private val seriesRepository: CreatorAdminContentSeriesRepository
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("소유한 active series만 요청 순서대로 1..n으로 재정렬하고 성공 응답은 null data이다")
|
||||
fun shouldReorderOwnedActiveSeriesInRequestOrder() {
|
||||
val character = createCharacter("series-order-success-character")
|
||||
val owner = character.creatorMember!!
|
||||
val first = saveSeries(owner, "first series", orders = 3)
|
||||
val second = saveSeries(owner, "second series", orders = 2)
|
||||
val third = saveSeries(owner, "third series", orders = 1)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/ai-characters/${character.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(mapOf("ids" to listOf(third.id, first.id, second.id))))
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.message").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(listOf(third.id, first.id, second.id), orderedSeriesIds(owner.id!!))
|
||||
assertEquals(listOf(1, 2, 3), orderedSeriesOrders(owner.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cross-owner, missing, duplicate, inactive series ID는 400 invalid_request로 거부하고 순서는 유지된다")
|
||||
fun shouldRejectInvalidSeriesIdsWithoutMutation() {
|
||||
val character = createCharacter("series-order-invalid-character")
|
||||
val owner = character.creatorMember!!
|
||||
val otherCharacter = createCharacter("series-order-invalid-other-character")
|
||||
val first = saveSeries(owner, "order invalid first", orders = 1)
|
||||
val second = saveSeries(owner, "order invalid second", orders = 2)
|
||||
val inactive = saveSeries(owner, "order invalid inactive", isActive = false, orders = 3)
|
||||
val foreign = saveSeries(otherCharacter.creatorMember!!, "order invalid foreign", orders = 1)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
listOf(first.id, foreign.id, second.id),
|
||||
listOf(first.id, Long.MAX_VALUE, second.id),
|
||||
listOf(first.id, first.id, second.id),
|
||||
listOf(first.id, inactive.id, second.id),
|
||||
emptyList<Long>()
|
||||
).forEach { ids ->
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/ai-characters/${character.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(mapOf("ids" to ids)))
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(listOf(first.id, second.id, inactive.id), orderedSeriesIds(owner.id!!))
|
||||
assertEquals(listOf(1, 2, 3), orderedSeriesOrders(owner.id!!))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("순서 변경 body에 계약 밖 field가 있으면 400으로 거부하고 순서를 변경하지 않는다")
|
||||
fun shouldRejectOrderUnknownFieldWithoutMutation() {
|
||||
val character = createCharacter("series-order-unknown-field-character")
|
||||
val owner = character.creatorMember!!
|
||||
val first = saveSeries(owner, "order unknown first", orders = 1)
|
||||
val second = saveSeries(owner, "order unknown second", orders = 2)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/ai-characters/${character.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"ids":[${second.id},${first.id}],"unexpected":true}""")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
).andExpectInvalidRequest()
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(listOf(first.id, second.id), orderedSeriesIds(owner.id!!))
|
||||
assertEquals(listOf(1, 2), orderedSeriesOrders(owner.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동일 owner 순서 변경은 마지막 요청 결과를 남긴다")
|
||||
fun shouldKeepLastOrderRequestResult() {
|
||||
val character = createCharacter("series-order-last-request-character")
|
||||
val owner = character.creatorMember!!
|
||||
val first = saveSeries(owner, "last order first", orders = 1)
|
||||
val second = saveSeries(owner, "last order second", orders = 2)
|
||||
val third = saveSeries(owner, "last order third", orders = 3)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/ai-characters/${character.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(mapOf("ids" to listOf(third.id, second.id, first.id))))
|
||||
.with(adminAuthentication())
|
||||
).andExpect(status().isOk)
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/ai-characters/${character.id}/series/orders")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(mapOf("ids" to listOf(second.id, first.id, third.id))))
|
||||
.with(adminAuthentication())
|
||||
).andExpect(status().isOk)
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertEquals(listOf(second.id, first.id, third.id), orderedSeriesIds(owner.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동시 순서 변경용 잠금은 ID 오름차순으로 owner active series를 선점한다")
|
||||
fun shouldLockOwnedActiveSeriesInIdOrderBeforeReorder() {
|
||||
val character = createCharacter("series-order-lock-character")
|
||||
val owner = character.creatorMember!!
|
||||
val first = saveSeries(owner, "lock first", orders = 1)
|
||||
val second = saveSeries(owner, "lock second", orders = 2)
|
||||
val third = saveSeries(owner, "lock third", orders = 3)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val lockedIds = seriesRepository.findActiveByCreatorIdAndIdInForUpdate(
|
||||
owner.id!!,
|
||||
listOf(third.id!!, first.id!!, second.id!!)
|
||||
).map { it.id }
|
||||
|
||||
assertEquals(listOf(first.id, second.id, third.id), lockedIds)
|
||||
}
|
||||
|
||||
private fun ResultActions.andExpectInvalidRequest(): ResultActions {
|
||||
return andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun saveSeries(
|
||||
owner: Member,
|
||||
title: String,
|
||||
isActive: Boolean = true,
|
||||
orders: Int
|
||||
): Series {
|
||||
return Series(
|
||||
title = title,
|
||||
introduction = "introduction for $title",
|
||||
publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON),
|
||||
isActive = isActive,
|
||||
orders = orders
|
||||
).apply {
|
||||
member = owner
|
||||
genre = saveGenre(title)
|
||||
coverImage = "series/${title.replace(" ", "-")}.png"
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveGenre(name: String): SeriesGenre {
|
||||
return SeriesGenre(genre = name).also(entityManager::persist)
|
||||
}
|
||||
|
||||
private fun orderedSeriesIds(ownerId: Long): List<Long> {
|
||||
return entityManager.createQuery(
|
||||
"select s.id from Series s where s.member.id = :ownerId order by s.orders asc, s.id asc",
|
||||
java.lang.Long::class.java
|
||||
)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.resultList
|
||||
.map(java.lang.Long::toLong)
|
||||
}
|
||||
|
||||
private fun orderedSeriesOrders(ownerId: Long): List<Int> {
|
||||
return entityManager.createQuery(
|
||||
"select s.orders from Series s where s.member.id = :ownerId order by s.orders asc, s.id asc",
|
||||
Int::class.javaObjectType
|
||||
)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.resultList
|
||||
.map { it as Int }
|
||||
}
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.content.hashtag.HashTag
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
|
||||
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.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.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 javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminSeriesQueryTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("시리즈 목록은 활성 소유 시리즈만 순서대로 레거시 전체 필드로 반환한다")
|
||||
fun shouldReturnExactLegacyListFieldsAndExcludeInactiveSeries() {
|
||||
val character = createCharacter("series-list-character")
|
||||
val owner = character.creatorMember!!
|
||||
val genre = saveGenre("series-list-genre")
|
||||
val first = saveSeries(
|
||||
owner = owner,
|
||||
genre = genre,
|
||||
title = "first series",
|
||||
orders = 1,
|
||||
state = SeriesState.SUSPEND,
|
||||
days = mutableSetOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED),
|
||||
isAdult = true,
|
||||
writer = null
|
||||
)
|
||||
val second = saveSeries(owner, genre, "second series", orders = 2)
|
||||
val inactive = saveSeries(owner, genre, "inactive series", orders = 0, isActive = false)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val result = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series")
|
||||
.param("page", "0")
|
||||
.param("size", "20")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data")
|
||||
val items = data.path("items")
|
||||
val item = items.path(0)
|
||||
assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(2, data.path("totalCount").asInt())
|
||||
assertEquals(listOf(first.id, second.id), items.map { it.path("seriesId").asLong() })
|
||||
assertTrue(items.none { it.path("seriesId").asLong() == inactive.id })
|
||||
assertEquals(
|
||||
setOf(
|
||||
"seriesId",
|
||||
"title",
|
||||
"introduction",
|
||||
"coverImageUrl",
|
||||
"publishedDaysOfWeek",
|
||||
"genreId",
|
||||
"isAdult",
|
||||
"state",
|
||||
"isActive",
|
||||
"writer",
|
||||
"studio"
|
||||
),
|
||||
item.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals("first series", item.path("title").asText())
|
||||
assertEquals("introduction for first series", item.path("introduction").asText())
|
||||
assertEquals("https://test.cloudfront.net/series/first-series.png", item.path("coverImageUrl").asText())
|
||||
assertEquals(setOf("MON", "WED"), item.path("publishedDaysOfWeek").map { it.asText() }.toSet())
|
||||
assertEquals(genre.id, item.path("genreId").asLong())
|
||||
assertTrue(item.path("isAdult").asBoolean())
|
||||
assertEquals("SUSPEND", item.path("state").asText())
|
||||
assertTrue(item.path("isActive").asBoolean())
|
||||
assertTrue(item.path("writer").isNull)
|
||||
assertEquals("studio", item.path("studio").asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("시리즈 상세 data는 목록 item과 동일한 필드와 타입으로 반환한다")
|
||||
fun shouldReturnDetailAsSingleListItemSchema() {
|
||||
val character = createCharacter("series-detail-character")
|
||||
val genre = saveGenre("series-detail-genre")
|
||||
val series = saveSeries(
|
||||
owner = character.creatorMember!!,
|
||||
genre = genre,
|
||||
title = "detail series",
|
||||
state = SeriesState.COMPLETE,
|
||||
days = mutableSetOf(SeriesPublishedDaysOfWeek.WED, SeriesPublishedDaysOfWeek.SUN),
|
||||
keywords = listOf("#night")
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val result = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data")
|
||||
assertEquals(
|
||||
setOf(
|
||||
"seriesId",
|
||||
"title",
|
||||
"introduction",
|
||||
"coverImageUrl",
|
||||
"publishedDaysOfWeek",
|
||||
"genreId",
|
||||
"isAdult",
|
||||
"state",
|
||||
"isActive",
|
||||
"writer",
|
||||
"studio"
|
||||
),
|
||||
data.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals(series.id, data.path("seriesId").asLong())
|
||||
assertEquals("detail series", data.path("title").asText())
|
||||
assertEquals("introduction for detail series", data.path("introduction").asText())
|
||||
assertEquals("https://test.cloudfront.net/series/detail-series.png", data.path("coverImageUrl").asText())
|
||||
assertEquals(setOf("SUN", "WED"), data.path("publishedDaysOfWeek").map { it.asText() }.toSet())
|
||||
assertEquals(genre.id, data.path("genreId").asLong())
|
||||
assertEquals("COMPLETE", data.path("state").asText())
|
||||
assertTrue(data.path("isActive").asBoolean())
|
||||
assertEquals("writer", data.path("writer").asText())
|
||||
assertEquals("studio", data.path("studio").asText())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("시리즈 상세는 미존재 비활성 타 소유자 리소스를 같은 400 오류로 거부한다")
|
||||
fun shouldRejectMissingInactiveAndCrossOwnerDetail() {
|
||||
val character = createCharacter("series-detail-owner-character")
|
||||
val otherCharacter = createCharacter("series-detail-other-character")
|
||||
val genre = saveGenre("series-invalid-detail-genre")
|
||||
val inactive = saveSeries(character.creatorMember!!, genre, "inactive detail", isActive = false)
|
||||
val foreign = saveSeries(otherCharacter.creatorMember!!, genre, "foreign detail")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(Long.MAX_VALUE, inactive.id!!, foreign.id!!).forEach { seriesId ->
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/$seriesId")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 AI 캐릭터 target의 시리즈 목록을 400으로 거부한다")
|
||||
fun shouldRejectInactiveTargetList() {
|
||||
val character = createCharacter("inactive-target-series-list-character")
|
||||
character.isActive = false
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 AI 캐릭터 target의 시리즈 상세를 400으로 거부한다")
|
||||
fun shouldRejectInactiveTargetDetail() {
|
||||
val character = createCharacter("inactive-target-series-detail-character")
|
||||
val series = saveSeries(
|
||||
owner = character.creatorMember!!,
|
||||
genre = saveGenre("inactive-target-series-detail-genre"),
|
||||
title = "inactive target detail"
|
||||
)
|
||||
character.isActive = false
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}")
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("시리즈 목록은 최소 size 1과 다음 page 경계를 그대로 적용한다")
|
||||
fun shouldRespectMinimumSizeAndNextPageBoundary() {
|
||||
val character = createCharacter("series-pagination-character")
|
||||
val genre = saveGenre("series-pagination-genre")
|
||||
val first = saveSeries(character.creatorMember!!, genre, "page first", orders = 1)
|
||||
val second = saveSeries(character.creatorMember!!, genre, "page second", orders = 2)
|
||||
saveSeries(character.creatorMember!!, genre, "page third", orders = 3)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series")
|
||||
.param("page", "1")
|
||||
.param("size", "1")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(3))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].seriesId").value(second.id))
|
||||
.andExpect(jsonPath("$.data.items[0].seriesId").value(org.hamcrest.Matchers.not(first.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("시리즈 목록은 음수 page와 0 size를 400으로 거부한다")
|
||||
fun shouldRejectInvalidPaginationBoundaries() {
|
||||
val character = createCharacter("series-invalid-pagination-character")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf("-1" to "20", "0" to "0").forEach { (page, size) ->
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/series")
|
||||
.param("page", page)
|
||||
.param("size", size)
|
||||
.header("Accept-Language", "en")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun saveGenre(name: String): SeriesGenre {
|
||||
return SeriesGenre(genre = name).also(entityManager::persist)
|
||||
}
|
||||
|
||||
private fun saveSeries(
|
||||
owner: Member,
|
||||
genre: SeriesGenre,
|
||||
title: String,
|
||||
orders: Int = 1,
|
||||
isActive: Boolean = true,
|
||||
state: SeriesState = SeriesState.PROCEEDING,
|
||||
days: MutableSet<SeriesPublishedDaysOfWeek> = mutableSetOf(SeriesPublishedDaysOfWeek.MON),
|
||||
keywords: List<String> = emptyList(),
|
||||
isAdult: Boolean = false,
|
||||
writer: String? = "writer"
|
||||
): Series {
|
||||
val series = Series(
|
||||
title = title,
|
||||
introduction = "introduction for $title",
|
||||
state = state,
|
||||
writer = writer,
|
||||
studio = "studio",
|
||||
publishedDaysOfWeek = days,
|
||||
isAdult = isAdult,
|
||||
isActive = isActive,
|
||||
orders = orders
|
||||
).apply {
|
||||
member = owner
|
||||
this.genre = genre
|
||||
coverImage = "series/${title.replace(" ", "-")}.png"
|
||||
}
|
||||
keywords.forEach { tag ->
|
||||
val hashTag = HashTag(tag).also(entityManager::persist)
|
||||
series.keywordList.add(
|
||||
SeriesKeyword().apply {
|
||||
this.series = series
|
||||
keyword = hashTag
|
||||
}
|
||||
)
|
||||
}
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
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.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.hashtag.HashTag
|
||||
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
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.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.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 LegacyCreatorAdminSeriesCharacterizationTest @Autowired constructor(
|
||||
private val repository: CreatorAdminContentSeriesRepository,
|
||||
private val adminRepository: AdminContentSeriesRepository,
|
||||
private val genreRepository: CreatorAdminContentSeriesGenreRepository,
|
||||
private val hashTagRepository: HashTagRepository,
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
private lateinit var eventPublisher: ApplicationEventPublisher
|
||||
private lateinit var service: CreatorAdminContentSeriesService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(URL("https://s3.example.com/series-cover"))
|
||||
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
service = CreatorAdminContentSeriesService(
|
||||
repository = repository,
|
||||
genreRepository = genreRepository,
|
||||
hashTagRepository = hashTagRepository,
|
||||
audioContentRepository = audioContentRepository,
|
||||
s3Uploader = S3Uploader(amazonS3Client),
|
||||
objectMapper = jacksonObjectMapper(),
|
||||
applicationEventPublisher = eventPublisher,
|
||||
coverImageBucket = "test-bucket",
|
||||
coverImageHost = "https://cover.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 시리즈 생성은 키워드를 정규화하고 표지 업로드와 언어 감지 이벤트를 남긴다")
|
||||
fun shouldCreateSeriesWithNormalizedKeywordsCoverAndLanguageDetectEvent() {
|
||||
val owner = saveMember("legacy-series-create-owner")
|
||||
val genre = saveGenre("legacy-create-genre")
|
||||
|
||||
service.createSeries(
|
||||
coverImage = MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1, 2, 3)),
|
||||
requestString = """
|
||||
{
|
||||
"title":"legacy created series",
|
||||
"introduction":"legacy introduction",
|
||||
"publishedDaysOfWeek":["MON","WED"],
|
||||
"keyword":"#night night #walk",
|
||||
"genreId":${genre.id},
|
||||
"isAdult":true,
|
||||
"writer":"legacy writer",
|
||||
"studio":"legacy studio"
|
||||
}
|
||||
""".trimIndent(),
|
||||
member = owner
|
||||
)
|
||||
entityManager.flush()
|
||||
|
||||
val created = repository.findAll().single { it.title == "legacy created series" }
|
||||
assertEquals(owner.id, created.member!!.id)
|
||||
assertEquals(genre.id, created.genre!!.id)
|
||||
assertEquals(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), created.publishedDaysOfWeek)
|
||||
assertEquals(setOf("#night", "#walk"), created.keywordList.map { it.keyword!!.tag }.toSet())
|
||||
assertTrue(created.coverImage!!.startsWith("series_cover/${created.id}/${created.id}-cover"))
|
||||
assertTrue(created.isActive)
|
||||
assertTrue(created.isAdult)
|
||||
Mockito.verify(amazonS3Client).putObject(Mockito.any())
|
||||
|
||||
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||
val event = eventCaptor.value as LanguageDetectEvent
|
||||
assertEquals(created.id, event.id)
|
||||
assertEquals("legacy created series legacy introduction #night night #walk", event.query)
|
||||
assertEquals(LanguageDetectTargetType.SERIES, event.targetType)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 목록은 활성 소유 시리즈만 순서대로 반환하지만 상세는 비활성 시리즈도 반환한다")
|
||||
fun shouldListOnlyActiveOwnedSeriesButAllowInactiveOwnedDetail() {
|
||||
val owner = saveMember("legacy-series-list-owner")
|
||||
val otherOwner = saveMember("legacy-series-list-other-owner")
|
||||
val genre = saveGenre("legacy-list-genre")
|
||||
val first = saveSeries(owner, genre, "first active series", orders = 1)
|
||||
val second = saveSeries(owner, genre, "second active series", orders = 2)
|
||||
val inactive = saveSeries(
|
||||
owner = owner,
|
||||
genre = genre,
|
||||
title = "inactive series",
|
||||
isActive = false,
|
||||
orders = 0,
|
||||
state = SeriesState.COMPLETE,
|
||||
days = mutableSetOf(SeriesPublishedDaysOfWeek.WED, SeriesPublishedDaysOfWeek.SUN),
|
||||
keywords = listOf("#inactive")
|
||||
)
|
||||
saveSeries(otherOwner, genre, "other owner series", orders = 0)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val response = service.getSeriesList(offset = 0, limit = 20, creatorId = owner.id!!)
|
||||
|
||||
assertEquals(2, response.totalCount)
|
||||
assertEquals(listOf(first.id, second.id), response.items.map { it.seriesId })
|
||||
assertEquals("https://cover.example.com/series/first-active-series.png", response.items.first().coverImageUrl)
|
||||
assertEquals(genre.id, response.items.first().genreId)
|
||||
assertTrue(response.items.all { it.isActive })
|
||||
|
||||
val inactiveDetail = service.getDetail(id = inactive.id!!, memberId = owner.id!!)
|
||||
assertEquals(inactive.id, inactiveDetail.seriesId)
|
||||
assertEquals("일, 수", inactiveDetail.publishedDaysOfWeek)
|
||||
assertEquals("완결", inactiveDetail.state)
|
||||
assertEquals("#inactive", inactiveDetail.keywords)
|
||||
assertNull(adminRepository.findByIdAndActiveTrue(inactive.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 수정은 일반 필드와 soft delete를 함께 반영하고 비활성 시리즈를 목록에서 제외한다")
|
||||
fun shouldModifyFieldsAndSoftDeleteInOneRequest() {
|
||||
val owner = saveMember("legacy-series-update-owner")
|
||||
val oldGenre = saveGenre("legacy-old-genre")
|
||||
val newGenre = saveGenre("legacy-new-genre")
|
||||
val series = saveSeries(owner, oldGenre, "before title")
|
||||
val originalCover = series.coverImage
|
||||
|
||||
service.modifySeries(
|
||||
coverImage = null,
|
||||
requestString = """
|
||||
{
|
||||
"seriesId":${series.id},
|
||||
"title":"after title",
|
||||
"introduction":"after introduction",
|
||||
"publishedDaysOfWeek":["FRI"],
|
||||
"genreId":${newGenre.id},
|
||||
"isAdult":true,
|
||||
"state":"COMPLETE",
|
||||
"isActive":false,
|
||||
"writer":"after writer",
|
||||
"studio":"after studio"
|
||||
}
|
||||
""".trimIndent(),
|
||||
member = owner
|
||||
)
|
||||
entityManager.flush()
|
||||
|
||||
assertEquals("after title", series.title)
|
||||
assertEquals("after introduction", series.introduction)
|
||||
assertEquals(setOf(SeriesPublishedDaysOfWeek.FRI), series.publishedDaysOfWeek)
|
||||
assertEquals(newGenre.id, series.genre!!.id)
|
||||
assertEquals(SeriesState.COMPLETE, series.state)
|
||||
assertEquals(originalCover, series.coverImage)
|
||||
assertTrue(series.isAdult)
|
||||
assertFalse(series.isActive)
|
||||
assertEquals("after writer", series.writer)
|
||||
assertEquals("after studio", series.studio)
|
||||
assertEquals(0, service.getSeriesList(0, 20, owner.id!!).totalCount)
|
||||
assertEquals("after title", service.getDetail(series.id!!, owner.id!!).title)
|
||||
assertNull(adminRepository.findByIdAndActiveTrue(series.id!!))
|
||||
|
||||
val eventCaptor = ArgumentCaptor.forClass(Any::class.java)
|
||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||
val event = eventCaptor.value as kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
assertEquals(series.id, event.id)
|
||||
assertEquals(kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType.SERIES, event.targetType)
|
||||
assertTrue(event.waitTransactionCommit)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 요청 검증과 소유권 실패는 고유 message key를 사용하고 generic 오류 번역은 세 언어로 유지된다")
|
||||
fun shouldExposeLegacyValidationOwnershipAndLocalizationBaseline() {
|
||||
val validRequest = CreateSeriesRequest(
|
||||
title = "title",
|
||||
introduction = "introduction",
|
||||
publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON),
|
||||
keyword = "#keyword",
|
||||
genreId = 1L
|
||||
)
|
||||
val validationKeys = listOf(
|
||||
assertThrows(SodaException::class.java) { validRequest.copy(title = " ").toSeries() }.messageKey,
|
||||
assertThrows(SodaException::class.java) { validRequest.copy(introduction = " ").toSeries() }.messageKey,
|
||||
assertThrows(SodaException::class.java) { validRequest.copy(keyword = " ").toSeries() }.messageKey,
|
||||
assertThrows(SodaException::class.java) { validRequest.copy(genreId = 0).toSeries() }.messageKey,
|
||||
assertThrows(SodaException::class.java) {
|
||||
validRequest.copy(publishedDaysOfWeek = mutableSetOf()).toSeries()
|
||||
}.messageKey,
|
||||
assertThrows(SodaException::class.java) {
|
||||
validRequest.copy(
|
||||
publishedDaysOfWeek = mutableSetOf(
|
||||
SeriesPublishedDaysOfWeek.RANDOM,
|
||||
SeriesPublishedDaysOfWeek.MON
|
||||
)
|
||||
).toSeries()
|
||||
}.messageKey
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"creator.admin.series.title_required",
|
||||
"creator.admin.series.introduction_required",
|
||||
"creator.admin.series.keyword_required",
|
||||
"creator.admin.series.genre_required",
|
||||
"creator.admin.series.published_days_required",
|
||||
"creator.admin.series.published_days_random_exclusive"
|
||||
),
|
||||
validationKeys
|
||||
)
|
||||
|
||||
val owner = saveMember("legacy-series-error-owner")
|
||||
val otherOwner = saveMember("legacy-series-error-other-owner")
|
||||
val genre = saveGenre("legacy-error-genre")
|
||||
val ownerSeries = saveSeries(owner, genre, "owner series")
|
||||
val otherSeries = saveSeries(otherOwner, genre, "other series")
|
||||
assertEquals(
|
||||
"creator.admin.series.cover_image_required",
|
||||
assertThrows(SodaException::class.java) { service.createSeries(null, "{}", owner) }.messageKey
|
||||
)
|
||||
assertEquals(
|
||||
"creator.admin.series.no_changes",
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.modifySeries(null, """{"seriesId":${ownerSeries.id}}""", owner)
|
||||
}.messageKey
|
||||
)
|
||||
assertEquals(
|
||||
"creator.admin.series.invalid_access",
|
||||
assertThrows(SodaException::class.java) { service.getDetail(otherSeries.id!!, owner.id!!) }.messageKey
|
||||
)
|
||||
assertEquals(
|
||||
"creator.admin.series.no_content_added",
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.addingContentToTheSeries(
|
||||
AddingContentToTheSeriesRequest(ownerSeries.id!!, emptyList()),
|
||||
owner.id!!
|
||||
)
|
||||
}.messageKey
|
||||
)
|
||||
assertEquals(
|
||||
listOf("잘못된 요청입니다.", "Invalid request.", "無効なリクエストです。"),
|
||||
Lang.values().map { messageSource.getMessage("common.error.invalid_request", it) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 콘텐츠 연결은 소유한 ID만 부분 반영하고 전체 owner count와 무해한 해제를 유지한다")
|
||||
fun shouldPartiallyLinkOwnedContentCountAcrossSeriesAndSilentlyUnlink() {
|
||||
val owner = saveMember("legacy-series-link-owner")
|
||||
val otherOwner = saveMember("legacy-series-link-other-owner")
|
||||
val genre = saveGenre("legacy-link-genre")
|
||||
val targetSeries = saveSeries(owner, genre, "target series")
|
||||
val secondSeries = saveSeries(owner, genre, "second series")
|
||||
val targetContent = saveAudioContent(owner, "target audio")
|
||||
val secondContent = saveAudioContent(owner, "second audio")
|
||||
val foreignContent = saveAudioContent(otherOwner, "foreign audio")
|
||||
|
||||
service.addingContentToTheSeries(
|
||||
AddingContentToTheSeriesRequest(
|
||||
targetSeries.id!!,
|
||||
listOf(targetContent.id!!, foreignContent.id!!, Long.MAX_VALUE)
|
||||
),
|
||||
owner.id!!
|
||||
)
|
||||
service.addingContentToTheSeries(
|
||||
AddingContentToTheSeriesRequest(secondSeries.id!!, listOf(secondContent.id!!)),
|
||||
owner.id!!
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val targetResponse = service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!)
|
||||
assertEquals(2, targetResponse.totalCount)
|
||||
assertEquals(listOf(targetContent.id), targetResponse.items.map { it.contentId })
|
||||
assertEquals("https://cover.example.com/audio/target-audio.png", targetResponse.items.single().coverImage)
|
||||
|
||||
val missingSeriesResponse = service.getSeriesContent(Long.MAX_VALUE, 0, 20, owner.id!!)
|
||||
assertEquals(2, missingSeriesResponse.totalCount)
|
||||
assertTrue(missingSeriesResponse.items.isEmpty())
|
||||
assertEquals(
|
||||
"creator.admin.series.no_content_added",
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.addingContentToTheSeries(
|
||||
AddingContentToTheSeriesRequest(targetSeries.id!!, listOf(foreignContent.id!!, Long.MAX_VALUE)),
|
||||
owner.id!!
|
||||
)
|
||||
}.messageKey
|
||||
)
|
||||
assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.size)
|
||||
|
||||
service.removeContentInTheSeries(
|
||||
RemoveContentToTheSeriesRequest(targetSeries.id!!, Long.MAX_VALUE),
|
||||
owner.id!!
|
||||
)
|
||||
assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.size)
|
||||
service.removeContentInTheSeries(
|
||||
RemoveContentToTheSeriesRequest(targetSeries.id!!, targetContent.id!!),
|
||||
owner.id!!
|
||||
)
|
||||
entityManager.flush()
|
||||
assertTrue(service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.isEmpty())
|
||||
assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).totalCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 미연결 검색은 처리 완료 또는 예약된 소유 콘텐츠만 반환하고 시리즈 소유권은 검증하지 않는다")
|
||||
fun shouldSearchEligibleUnlinkedOwnerContentWithoutSeriesOwnershipValidation() {
|
||||
val owner = saveMember("legacy-series-search-owner")
|
||||
val otherOwner = saveMember("legacy-series-search-other-owner")
|
||||
val genre = saveGenre("legacy-search-genre")
|
||||
val targetSeries = saveSeries(owner, genre, "search target series")
|
||||
val linked = saveAudioContent(owner, "night linked")
|
||||
val eligible = saveAudioContent(owner, "night eligible")
|
||||
val reserved = saveAudioContent(
|
||||
owner = owner,
|
||||
title = "night reserved",
|
||||
isActive = false,
|
||||
releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0)
|
||||
)
|
||||
saveAudioContent(owner, "night processing", duration = null)
|
||||
saveAudioContent(owner, "night inactive", isActive = false, releaseDate = null)
|
||||
saveAudioContent(otherOwner, "night foreign")
|
||||
service.addingContentToTheSeries(
|
||||
AddingContentToTheSeriesRequest(targetSeries.id!!, listOf(linked.id!!)),
|
||||
owner.id!!
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val result = service.searchContentNotInSeries(targetSeries.id!!, "night", owner.id!!)
|
||||
assertEquals(setOf(eligible.id, reserved.id), result.map { it.contentId }.toSet())
|
||||
assertTrue(result.all { it.coverImage.startsWith("https://cover.example.com/audio/") })
|
||||
|
||||
val missingSeriesResult = service.searchContentNotInSeries(Long.MAX_VALUE, "night", owner.id!!)
|
||||
assertEquals(setOf(linked.id, eligible.id, reserved.id), missingSeriesResult.map { it.contentId }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 순서 변경은 owner와 활성 상태를 검사하지 않고 누락 ID의 순번을 건너뛴다")
|
||||
fun shouldUpdateOrdersAcrossOwnersAndSkipMissingIds() {
|
||||
val owner = saveMember("legacy-series-order-owner")
|
||||
val otherOwner = saveMember("legacy-series-order-other-owner")
|
||||
val genre = saveGenre("legacy-order-genre")
|
||||
val ownedSeries = saveSeries(owner, genre, "owned order series", orders = 10)
|
||||
val foreignInactiveSeries = saveSeries(
|
||||
owner = otherOwner,
|
||||
genre = genre,
|
||||
title = "foreign inactive order series",
|
||||
isActive = false,
|
||||
orders = 10
|
||||
)
|
||||
|
||||
service.updateSeriesOrders(listOf(foreignInactiveSeries.id!!, Long.MAX_VALUE, ownedSeries.id!!))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
assertEquals(1, repository.findById(foreignInactiveSeries.id!!).orElseThrow().orders)
|
||||
assertFalse(repository.findById(foreignInactiveSeries.id!!).orElseThrow().isActive)
|
||||
assertEquals(3, repository.findById(ownedSeries.id!!).orElseThrow().orders)
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@example.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveGenre(name: String): SeriesGenre {
|
||||
val genre = SeriesGenre(genre = name)
|
||||
entityManager.persist(genre)
|
||||
return genre
|
||||
}
|
||||
|
||||
private fun saveSeries(
|
||||
owner: Member,
|
||||
genre: SeriesGenre,
|
||||
title: String,
|
||||
isActive: Boolean = true,
|
||||
orders: Int = 1,
|
||||
state: SeriesState = SeriesState.PROCEEDING,
|
||||
days: MutableSet<SeriesPublishedDaysOfWeek> = mutableSetOf(SeriesPublishedDaysOfWeek.MON),
|
||||
keywords: List<String> = emptyList()
|
||||
): Series {
|
||||
val series = Series(
|
||||
title = title,
|
||||
introduction = "introduction for $title",
|
||||
state = state,
|
||||
writer = "writer",
|
||||
studio = "studio",
|
||||
publishedDaysOfWeek = days,
|
||||
isActive = isActive,
|
||||
orders = orders
|
||||
).apply {
|
||||
member = owner
|
||||
this.genre = genre
|
||||
coverImage = "series/${title.replace(" ", "-")}.png"
|
||||
}
|
||||
keywords.forEach { tag ->
|
||||
val hashTag = HashTag(tag)
|
||||
entityManager.persist(hashTag)
|
||||
series.keywordList.add(
|
||||
SeriesKeyword().apply {
|
||||
this.series = series
|
||||
keyword = hashTag
|
||||
}
|
||||
)
|
||||
}
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveAudioContent(
|
||||
owner: Member,
|
||||
title: String,
|
||||
isActive: Boolean = true,
|
||||
releaseDate: LocalDateTime? = LocalDateTime.of(2026, 7, 24, 10, 0),
|
||||
duration: String? = "00:10:00"
|
||||
): AudioContent {
|
||||
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
|
||||
entityManager.persist(theme)
|
||||
val content = AudioContent(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
limited = 10,
|
||||
remaining = 5,
|
||||
releaseDate = releaseDate
|
||||
).apply {
|
||||
member = owner
|
||||
this.theme = theme
|
||||
this.isActive = isActive
|
||||
this.duration = duration
|
||||
this.content = "audio/${title.replace(" ", "-")}.mp3"
|
||||
coverImage = "audio/${title.replace(" ", "-")}.png"
|
||||
}
|
||||
entityManager.persist(content)
|
||||
return content
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user