feat(ai-character): 시리즈 관리자 API를 추가한다
This commit is contained in:
@@ -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.QSearchContentNotInSeriesResponse
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
|
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
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 {
|
interface CreatorAdminContentSeriesQueryRepository {
|
||||||
fun findByIdAndCreatorId(id: Long, creatorId: Long): Series?
|
fun findByIdAndCreatorId(id: Long, creatorId: Long): Series?
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>?
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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