test #443

Merged
klaus merged 25 commits from test into main 2026-08-03 06:20:48 +00:00
13 changed files with 3497 additions and 1 deletions
Showing only changes of commit 79b4436812 - Show all commits

View File

@@ -7,8 +7,19 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri
import kr.co.vividnext.sodalive.creator.admin.content.series.content.QSearchContentNotInSeriesResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface CreatorAdminContentSeriesRepository : JpaRepository<Series, Long>, CreatorAdminContentSeriesQueryRepository
interface CreatorAdminContentSeriesRepository : JpaRepository<Series, Long>, CreatorAdminContentSeriesQueryRepository {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Series s where s.member.id = :creatorId and s.id in :ids and s.isActive = true order by s.id asc")
fun findActiveByCreatorIdAndIdInForUpdate(
@Param("creatorId") creatorId: Long,
@Param("ids") ids: List<Long>
): List<Series>
}
interface CreatorAdminContentSeriesQueryRepository {
fun findByIdAndCreatorId(id: Long, creatorId: Long): Series?

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.HttpMediaTypeNotSupportedException
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.multipart.MultipartHttpServletRequest
@RestController
@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/series")
class AiCharacterAdminSeriesController(
private val facade: AiCharacterAdminSeriesFacade
) {
@GetMapping
fun list(
@PathVariable characterId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminSeriesListResponse> {
return ApiResponse.ok(facade.list(characterId, page, size))
}
@GetMapping("/{seriesId:[0-9]+}")
fun detail(
@PathVariable characterId: Long,
@PathVariable seriesId: Long
): ApiResponse<AiCharacterAdminSeriesDetailResponse> {
return ApiResponse.ok(facade.detail(characterId, seriesId))
}
@GetMapping("/{seriesId:[0-9]+}/contents")
fun contents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = ApiResponse.ok(facade.contents(characterId, seriesId, page, size))
@GetMapping("/{seriesId:[0-9]+}/contents/search")
fun searchContents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestParam("search_word") searchWord: String
) = ApiResponse.ok(facade.searchContents(characterId, seriesId, searchWord))
@PostMapping("/{seriesId:[0-9]+}/contents", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun addContents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.addContents(characterId, seriesId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{seriesId:[0-9]+}/contents/{contentId:[0-9]+}")
fun removeContent(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@PathVariable contentId: Long
): ApiResponse<Nothing> {
facade.removeContent(characterId, seriesId, contentId)
return ApiResponse.ok(null)
}
@PutMapping("/orders", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun updateOrders(
@PathVariable characterId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateOrders(characterId, request)
return ApiResponse.ok(null)
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.create(characterId, image, request)
return ApiResponse.ok(null)
}
@PutMapping("/{seriesId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, seriesId, image, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) {
if (multipartRequest.fileMap.keys.any { it !in SERIES_MULTIPART_PARTS } ||
multipartRequest.parts.any { it.name !in SERIES_MULTIPART_PARTS }
) {
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
}
}
companion object {
private val SERIES_MULTIPART_PARTS = setOf("image", "request")
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
typealias AiCharacterAdminSeriesListResponse = GetCreatorAdminContentSeriesListResponse
typealias AiCharacterAdminSeriesListItem = GetCreatorAdminContentSeriesListItem
typealias AiCharacterAdminSeriesDetailResponse = GetCreatorAdminContentSeriesListItem
typealias AiCharacterAdminSeriesCreateRequest = CreateSeriesRequest
data class AiCharacterAdminSeriesUpdateRequest(
val title: String? = null,
val introduction: String? = null,
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>? = null,
val genreId: Long? = null,
val isAdult: Boolean? = null,
val state: SeriesState? = null,
val isActive: Boolean? = null,
val writer: String? = null,
val studio: String? = null
)
data class AiCharacterAdminSeriesContentAddRequest(
val contentIdList: List<Long>?
)

View File

@@ -0,0 +1,220 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreService
import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesContentResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem
import kr.co.vividnext.sodalive.creator.admin.content.series.ModifySeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.UpdateOrdersRequest
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.content.SearchContentNotInSeriesResponse
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AiCharacterAdminSeriesFacade(
private val objectMapper: ObjectMapper,
private val targetResolver: AiCharacterAdminTargetResolver,
private val repository: AiCharacterAdminSeriesRepository,
private val legacyService: CreatorAdminContentSeriesService,
private val genreService: AdminContentSeriesGenreService,
@Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String
) {
@Transactional(readOnly = true)
fun genres(): List<GetSeriesGenreListResponse> {
return genreService.getSeriesGenreList()
}
@Transactional(readOnly = true)
fun list(characterId: Long, page: Int, size: Int): AiCharacterAdminSeriesListResponse {
val target = resolveActiveTarget(characterId)
if (page < 0 || size < 1) throw invalidRequest()
val pageable = PageRequest.of(page, size)
return legacyService.getSeriesList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
creatorId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional(readOnly = true)
fun detail(characterId: Long, seriesId: Long): AiCharacterAdminSeriesDetailResponse {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId)
?: throw invalidRequest()
return GetCreatorAdminContentSeriesListItem(
seriesId = series.id!!,
title = series.title,
introduction = series.introduction,
coverImageUrl = "$coverImageHost/${series.coverImage!!}",
publishedDaysOfWeek = series.publishedDaysOfWeek.toList(),
genreId = series.genre!!.id!!,
isAdult = series.isAdult,
state = series.state,
isActive = series.isActive,
writer = series.writer,
studio = series.studio
)
}
@Transactional(readOnly = true)
fun contents(
characterId: Long,
seriesId: Long,
page: Int,
size: Int
): GetCreatorAdminContentSeriesContentResponse {
val target = resolveOwnedActiveSeries(characterId, seriesId)
if (page < 0 || size < 1) throw invalidRequest()
val pageable = PageRequest.of(page, size)
return legacyService.getSeriesContent(
seriesId = seriesId,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
creatorId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional(readOnly = true)
fun searchContents(characterId: Long, seriesId: Long, searchWord: String): List<SearchContentNotInSeriesResponse> {
val target = resolveOwnedActiveSeries(characterId, seriesId)
return legacyService.searchContentNotInSeries(
seriesId = seriesId,
searchWord = searchWord,
memberId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional
fun addContents(characterId: Long, seriesId: Long, requestString: String) {
val request = readRequest(requestString, AiCharacterAdminSeriesContentAddRequest::class.java)
val target = resolveOwnedActiveSeries(characterId, seriesId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val contentIds = request.contentIdList ?: throw invalidRequest()
if (contentIds.isEmpty()) {
legacyService.addingContentToTheSeries(
AddingContentToTheSeriesRequest(seriesId, contentIds),
creatorMemberId
)
return
}
if (contentIds.size != contentIds.toSet().size) throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
val linkedContentIds = series.contentList.mapNotNull { it.content?.id }.toSet()
contentIds.forEach { contentId ->
if (contentId in linkedContentIds ||
repository.findEligibleContentByIdAndCreatorMemberId(contentId, creatorMemberId) == null
) {
throw invalidRequest()
}
}
legacyService.addingContentToTheSeries(AddingContentToTheSeriesRequest(seriesId, contentIds), creatorMemberId)
}
@Transactional
fun removeContent(characterId: Long, seriesId: Long, contentId: Long?) {
val target = resolveOwnedActiveSeries(characterId, seriesId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val resolvedContentId = contentId ?: throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
if (series.contentList.none { it.content?.id == resolvedContentId && it.content?.member?.id == creatorMemberId }) {
throw invalidRequest()
}
legacyService.removeContentInTheSeries(RemoveContentToTheSeriesRequest(seriesId, resolvedContentId), creatorMemberId)
}
@Transactional
fun updateOrders(characterId: Long, requestString: String) {
val request = readRequest(requestString, UpdateOrdersRequest::class.java)
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val ids = request.ids
if (ids.isEmpty() || ids.size != ids.toSet().size) throw invalidRequest()
val lockedIds = repository.findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId, ids).map { it.id }.toSet()
if (lockedIds != ids.toSet()) throw invalidRequest()
legacyService.updateSeriesOrders(ids)
}
@Transactional
fun create(characterId: Long, image: MultipartFile, requestString: String) {
val target = resolveActiveTarget(characterId)
if (image.isEmpty) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminSeriesCreateRequest::class.java)
rejectMissingActiveGenre(request.genreId)
legacyService.createSeries(image, requestString, target.creatorMember)
}
@Transactional
fun update(characterId: Long, seriesId: Long, image: MultipartFile?, requestString: String) {
val target = resolveOwnedActiveSeries(characterId, seriesId)
val normalizedImage = image?.takeUnless { it.isEmpty }
val request = readRequest(requestString, AiCharacterAdminSeriesUpdateRequest::class.java)
request.genreId?.let(::rejectMissingActiveGenre)
legacyService.modifySeries(
coverImage = normalizedImage,
requestString = objectMapper.writeValueAsString(
ModifySeriesRequest(
seriesId = seriesId,
title = request.title,
introduction = request.introduction,
publishedDaysOfWeek = request.publishedDaysOfWeek,
genreId = request.genreId,
isAdult = request.isAdult,
state = request.state,
isActive = request.isActive,
writer = request.writer,
studio = request.studio
)
),
member = target.creatorMember
)
}
private fun resolveOwnedActiveSeries(characterId: Long, seriesId: Long): AiCharacterAdminTarget {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
return target
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
return target
}
private fun rejectMissingActiveGenre(genreId: Long) {
if (genreId <= 0 || !repository.existsActiveGenre(genreId)) throw invalidRequest()
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminSeriesReferenceController(
private val facade: AiCharacterAdminSeriesFacade
) {
@GetMapping("/series-genres")
fun genres(): ApiResponse<List<GetSeriesGenreListResponse>> {
return ApiResponse.ok(facade.genres())
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
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.genre.CreatorAdminContentSeriesGenreRepository
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminSeriesRepository(
private val repository: CreatorAdminContentSeriesRepository,
private val genreRepository: CreatorAdminContentSeriesGenreRepository,
private val audioContentRepository: AudioContentRepository
) {
fun findActiveByIdAndCreatorMemberId(seriesId: Long, creatorMemberId: Long): Series? {
return repository.findByIdAndCreatorId(seriesId, creatorMemberId)?.takeIf { it.isActive }
}
fun findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId: Long, ids: List<Long>): List<Series> {
return repository.findActiveByCreatorIdAndIdInForUpdate(creatorMemberId, ids.sorted())
}
fun existsActiveGenre(genreId: Long): Boolean {
return genreRepository.findById(genreId).orElse(null)?.isActive == true
}
fun findEligibleContentByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return audioContentRepository.findByIdAndCreatorId(contentId, creatorMemberId)
?.takeIf { it.duration != null && (it.isActive || it.releaseDate != null) }
}
}

View File

@@ -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"))
)
)
}

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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"))
)
)
}

View File

@@ -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"))
)
)
}

View File

@@ -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
}
}