test #443
@@ -0,0 +1,138 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
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]+}/community-posts")
|
||||
class AiCharacterAdminCommunityPostController(
|
||||
private val facade: AiCharacterAdminCommunityPostFacade
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminCommunityPostListResponse> {
|
||||
return ApiResponse.ok(facade.list(characterId, page, size))
|
||||
}
|
||||
|
||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun create(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?,
|
||||
@RequestPart(value = "postImage", required = false) postImage: MultipartFile?,
|
||||
@RequestPart("request") request: String,
|
||||
multipartRequest: MultipartHttpServletRequest
|
||||
): ApiResponse<Nothing> {
|
||||
requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS)
|
||||
requireJsonRequestPart(multipartRequest)
|
||||
facade.create(characterId, audioFile, postImage, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping("/{postId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
fun update(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable postId: Long,
|
||||
@RequestPart(value = "postImage", required = false) postImage: MultipartFile?,
|
||||
@RequestPart("request") request: String,
|
||||
multipartRequest: MultipartHttpServletRequest
|
||||
): ApiResponse<Nothing> {
|
||||
requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS)
|
||||
requireJsonRequestPart(multipartRequest)
|
||||
facade.update(characterId, postId, postImage, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
private fun requireAllowedMultipartParts(
|
||||
multipartRequest: MultipartHttpServletRequest,
|
||||
allowedParts: Set<String>
|
||||
) {
|
||||
if (multipartRequest.fileMap.keys.any { it !in allowedParts } ||
|
||||
multipartRequest.parts.any { it.name !in allowedParts }
|
||||
) {
|
||||
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 CREATE_MULTIPART_PARTS = setOf("audioFile", "postImage", "request")
|
||||
private val UPDATE_MULTIPART_PARTS = setOf("postImage", "request")
|
||||
}
|
||||
|
||||
@GetMapping("/{postId:[0-9]+}/comments")
|
||||
fun comments(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable postId: Long,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminCommunityPostCommentListResponse> {
|
||||
return ApiResponse.ok(facade.comments(characterId, postId, page, size))
|
||||
}
|
||||
|
||||
@PostMapping("/{postId:[0-9]+}/comments", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun createComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable postId: Long,
|
||||
@RequestBody request: String
|
||||
): ApiResponse<Nothing> {
|
||||
facade.createComment(characterId, postId, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun updateComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable postId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestBody request: String
|
||||
): ApiResponse<Nothing> {
|
||||
facade.updateComment(characterId, postId, commentId, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}")
|
||||
fun deleteComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable postId: Long,
|
||||
@PathVariable commentId: Long
|
||||
): ApiResponse<Nothing> {
|
||||
facade.deleteComment(characterId, postId, commentId)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@GetMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}/replies")
|
||||
fun commentReplies(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable postId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminCommunityPostCommentListResponse> {
|
||||
return ApiResponse.ok(facade.commentReplies(characterId, postId, commentId, page, size))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListResponse
|
||||
|
||||
typealias AiCharacterAdminCommunityPostDto = GetCommunityPostListResponse
|
||||
typealias AiCharacterAdminCommunityPostCommentListResponse = GetCommunityPostCommentListResponse
|
||||
|
||||
data class AiCharacterAdminCommunityPostListResponse(
|
||||
val totalCount: Long,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val items: List<AiCharacterAdminCommunityPostDto>
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCommunityPostUpdateRequest(
|
||||
val content: String? = null,
|
||||
val isCommentAvailable: Boolean? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val isActive: Boolean? = null,
|
||||
val isFixed: Boolean? = null
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCommunityPostCommentCreateRequest(
|
||||
val comment: String,
|
||||
val parentId: Long? = null,
|
||||
val isSecret: Boolean = false
|
||||
)
|
||||
|
||||
data class AiCharacterAdminCommunityPostCommentUpdateRequest(
|
||||
val comment: String
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreateCommunityPostRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.ModifyCommunityPostRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.UpdateCommunityPostFixedRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||
import kr.co.vividnext.sodalive.extensions.getTimeAgoString
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
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
|
||||
import java.time.ZoneId
|
||||
|
||||
@Service
|
||||
class AiCharacterAdminCommunityPostFacade(
|
||||
private val targetResolver: AiCharacterAdminTargetResolver,
|
||||
private val legacyService: CreatorCommunityService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AiCharacterAdminCommunityPostRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val likeRepository: CreatorCommunityLikeRepository,
|
||||
private val commentRepository: CreatorCommunityCommentRepository,
|
||||
private val audioContentCloudFront: AudioContentCloudFront,
|
||||
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
|
||||
) {
|
||||
@Transactional
|
||||
fun create(characterId: Long, audioFile: MultipartFile?, postImage: MultipartFile?, request: String) {
|
||||
val target = resolveActiveTarget(characterId)
|
||||
readRequest(request, CreateCommunityPostRequest::class.java)
|
||||
legacyService.createCommunityPost(audioFile, postImage, request, target.creatorMember)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(characterId: Long, postId: Long, postImage: MultipartFile?, requestString: String) {
|
||||
val target = resolveActiveTarget(characterId)
|
||||
val creatorMember = target.creatorMember
|
||||
val creatorMemberId = creatorMember.id ?: throw invalidRequest()
|
||||
repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest()
|
||||
|
||||
rejectExplicitNull(requestString, "isFixed")
|
||||
val request = readRequest(requestString, AiCharacterAdminCommunityPostUpdateRequest::class.java)
|
||||
val modifyRequest = ModifyCommunityPostRequest(
|
||||
creatorCommunityId = postId,
|
||||
content = request.content,
|
||||
isCommentAvailable = request.isCommentAvailable,
|
||||
isAdult = request.isAdult,
|
||||
isActive = request.isActive
|
||||
)
|
||||
|
||||
if (request.isActive != false && request.isFixed != null) {
|
||||
memberRepository.findByIdForUpdate(creatorMemberId) ?: throw invalidRequest()
|
||||
legacyService.updateCommunityPostFixed(
|
||||
UpdateCommunityPostFixedRequest(postId = postId, isFixed = request.isFixed),
|
||||
creatorMember
|
||||
)
|
||||
}
|
||||
|
||||
legacyService.modifyCommunityPost(postImage, objectMapper.writeValueAsString(modifyRequest), creatorMember)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(
|
||||
characterId: Long,
|
||||
page: Int,
|
||||
size: Int
|
||||
): AiCharacterAdminCommunityPostListResponse {
|
||||
val target = resolveActiveTarget(characterId)
|
||||
if (page < 0 || size < 1) throw invalidRequest()
|
||||
|
||||
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
val pageable = PageRequest.of(page, size)
|
||||
val items = repository.findActiveByCreatorMemberId(creatorMemberId, pageable)
|
||||
.map { post -> post.toResponse(target.creatorMember, creatorMemberId) }
|
||||
val totalCount = repository.countActiveByCreatorMemberId(creatorMemberId)
|
||||
|
||||
return AiCharacterAdminCommunityPostListResponse(
|
||||
totalCount = totalCount,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = (page + 1L) * size < totalCount,
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun comments(
|
||||
characterId: Long,
|
||||
postId: Long,
|
||||
page: Int,
|
||||
size: Int
|
||||
): AiCharacterAdminCommunityPostCommentListResponse {
|
||||
val target = resolveOwnedActivePost(characterId, postId)
|
||||
validateListRequest(page, size)
|
||||
val pageable = PageRequest.of(page, size)
|
||||
return utcCommentDates(
|
||||
legacyService.getCommunityPostCommentList(
|
||||
postId = postId,
|
||||
memberId = target.creatorMember.id ?: throw invalidRequest(),
|
||||
timezone = UTC_TIMEZONE,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong(),
|
||||
isAdult = true
|
||||
),
|
||||
postId
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun commentReplies(
|
||||
characterId: Long,
|
||||
postId: Long,
|
||||
commentId: Long,
|
||||
page: Int,
|
||||
size: Int
|
||||
): AiCharacterAdminCommunityPostCommentListResponse {
|
||||
val target = resolveOwnedActivePost(characterId, postId)
|
||||
validateListRequest(page, size)
|
||||
repository.findActiveRootCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
|
||||
val pageable = PageRequest.of(page, size)
|
||||
return utcCommentDates(
|
||||
legacyService.getCommentReplyList(
|
||||
commentId = commentId,
|
||||
memberId = target.creatorMember.id ?: throw invalidRequest(),
|
||||
timezone = UTC_TIMEZONE,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong(),
|
||||
isAdult = true
|
||||
),
|
||||
postId
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createComment(characterId: Long, postId: Long, requestString: String) {
|
||||
val target = resolveOwnedActivePost(characterId, postId)
|
||||
val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentCreateRequest::class.java)
|
||||
request.parentId?.let { parentId ->
|
||||
repository.findActiveRootCommentByIdAndPostId(parentId, postId) ?: throw invalidRequest()
|
||||
}
|
||||
legacyService.createCommunityPostComment(
|
||||
member = target.creatorMember,
|
||||
comment = request.comment,
|
||||
postId = postId,
|
||||
parentId = request.parentId,
|
||||
isSecret = request.isSecret,
|
||||
isAdult = true
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateComment(characterId: Long, postId: Long, commentId: Long, requestString: String) {
|
||||
val target = resolveOwnedActivePost(characterId, postId)
|
||||
val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentUpdateRequest::class.java)
|
||||
val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
|
||||
if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest()
|
||||
legacyService.modifyCommunityPostComment(
|
||||
ModifyCommunityPostCommentRequest(commentId = commentId, comment = request.comment, isActive = null),
|
||||
target.creatorMember
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteComment(characterId: Long, postId: Long, commentId: Long) {
|
||||
val target = resolveOwnedActivePost(characterId, postId)
|
||||
val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
|
||||
if (!comment.isActive) return
|
||||
legacyService.modifyCommunityPostComment(
|
||||
ModifyCommunityPostCommentRequest(commentId = commentId, comment = null, isActive = false),
|
||||
target.creatorMember
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorCommunity.toResponse(member: Member, creatorMemberId: Long): AiCharacterAdminCommunityPostDto {
|
||||
val postId = id ?: throw invalidRequest()
|
||||
val createdAt = createdAt ?: throw invalidRequest()
|
||||
val audioUrl = audioPath?.let {
|
||||
audioContentCloudFront.generateSignedURL(it, AUDIO_URL_EXPIRATION_MILLIS)
|
||||
}
|
||||
val commentCount = if (isCommentAvailable) {
|
||||
commentRepository.totalCountCommentByPostId(
|
||||
postId = postId,
|
||||
memberId = creatorMemberId,
|
||||
isContentCreator = true
|
||||
)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return AiCharacterAdminCommunityPostDto(
|
||||
postId = postId,
|
||||
creatorId = creatorMemberId,
|
||||
creatorNickname = member.nickname,
|
||||
creatorProfileUrl = "$imageHost/${member.profileImage ?: DEFAULT_PROFILE_IMAGE_PATH}",
|
||||
imageUrl = imagePath?.let { "$imageHost/$it" },
|
||||
audioUrl = audioUrl,
|
||||
content = content,
|
||||
price = price,
|
||||
date = createdAt.getTimeAgoString(),
|
||||
dateUtc = createdAt.atZone(ZoneId.of("UTC")).toInstant().toString(),
|
||||
isCommentAvailable = isCommentAvailable,
|
||||
isAdult = isAdult,
|
||||
isFixed = isFixed,
|
||||
isLike = false,
|
||||
existOrdered = true,
|
||||
likeCount = likeRepository.totalCountCommunityPostLikeByPostId(postId),
|
||||
commentCount = commentCount,
|
||||
firstComment = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
|
||||
val target = targetResolver.resolve(characterId)
|
||||
if (!target.chatCharacter.isActive) throw invalidRequest()
|
||||
return target
|
||||
}
|
||||
|
||||
private fun resolveOwnedActivePost(characterId: Long, postId: Long): AiCharacterAdminTarget {
|
||||
val target = resolveActiveTarget(characterId)
|
||||
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest()
|
||||
return target
|
||||
}
|
||||
|
||||
private fun validateListRequest(page: Int, size: Int) {
|
||||
if (page < 0 || size < 1) throw invalidRequest()
|
||||
}
|
||||
|
||||
private fun utcCommentDates(
|
||||
response: AiCharacterAdminCommunityPostCommentListResponse,
|
||||
postId: Long
|
||||
): AiCharacterAdminCommunityPostCommentListResponse {
|
||||
return response.copy(items = response.items.map { it.withUtcDate(postId) })
|
||||
}
|
||||
|
||||
private fun GetCommunityPostCommentListItem.withUtcDate(postId: Long): GetCommunityPostCommentListItem {
|
||||
val createdAt = repository.findCommentByIdAndPostId(id, postId)?.createdAt ?: throw invalidRequest()
|
||||
return copy(date = createdAt.toUtcIso())
|
||||
}
|
||||
|
||||
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)
|
||||
.with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
|
||||
.readValue(requestString)
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejectExplicitNull(requestString: String, fieldName: String) {
|
||||
try {
|
||||
if (objectMapper.readTree(requestString).path(fieldName).isNull) throw invalidRequest()
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidRequest(): AiCharacterAdminApiException {
|
||||
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AUDIO_URL_EXPIRATION_MILLIS = 1_800_000L
|
||||
const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default_profile.png"
|
||||
const val UTC_TIMEZONE = "UTC"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
class AiCharacterAdminCommunityPostRepository(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) {
|
||||
fun findActiveByCreatorMemberId(creatorMemberId: Long, pageable: Pageable): List<CreatorCommunity> {
|
||||
return queryFactory
|
||||
.selectFrom(creatorCommunity)
|
||||
.where(
|
||||
creatorCommunity.member.id.eq(creatorMemberId)
|
||||
.and(creatorCommunity.isActive.isTrue)
|
||||
)
|
||||
.offset(pageable.offset)
|
||||
.limit(pageable.pageSize.toLong())
|
||||
.orderBy(
|
||||
creatorCommunity.isFixed.desc(),
|
||||
creatorCommunity.fixedAt.desc().nullsLast(),
|
||||
creatorCommunity.createdAt.desc()
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun countActiveByCreatorMemberId(creatorMemberId: Long): Long {
|
||||
return queryFactory
|
||||
.select(creatorCommunity.count())
|
||||
.from(creatorCommunity)
|
||||
.where(
|
||||
creatorCommunity.member.id.eq(creatorMemberId)
|
||||
.and(creatorCommunity.isActive.isTrue)
|
||||
)
|
||||
.fetchOne() ?: 0L
|
||||
}
|
||||
|
||||
fun findActiveByIdAndCreatorMemberId(postId: Long, creatorMemberId: Long): CreatorCommunity? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorCommunity)
|
||||
.where(
|
||||
creatorCommunity.id.eq(postId)
|
||||
.and(creatorCommunity.member.id.eq(creatorMemberId))
|
||||
.and(creatorCommunity.isActive.isTrue)
|
||||
)
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
fun findCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorCommunityComment)
|
||||
.where(
|
||||
creatorCommunityComment.id.eq(commentId)
|
||||
.and(creatorCommunityComment.creatorCommunity.id.eq(postId))
|
||||
)
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
fun findActiveRootCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorCommunityComment)
|
||||
.where(
|
||||
creatorCommunityComment.id.eq(commentId)
|
||||
.and(creatorCommunityComment.creatorCommunity.id.eq(postId))
|
||||
.and(creatorCommunityComment.parent.isNull)
|
||||
.and(creatorCommunityComment.isActive.isTrue)
|
||||
)
|
||||
.fetchOne()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.hamcrest.Matchers.nullValue
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.CsvSource
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
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 AiCharacterAdminCommunityPostCommentTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
|
||||
@Test
|
||||
@DisplayName("원댓글과 답글 목록은 timezone 없이 UTC Z date와 기존 totalCount 및 items를 반환한다")
|
||||
fun shouldListRootCommentsAndRepliesWithUtcDates() {
|
||||
registerMysqlDateFunctions()
|
||||
val character = createCharacter("community-comment-list")
|
||||
val post = savePost(character.creatorMember!!, "comment list post")
|
||||
val root = saveComment(post, character.creatorMember!!, "root")
|
||||
val reply = saveComment(post, character.creatorMember!!, "reply", root)
|
||||
root.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 1)
|
||||
reply.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 2)
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
get(commentsPath(character.id!!, post.id!!))
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(root.id))
|
||||
.andExpect(jsonPath("$.data.items[0].comment").value("root"))
|
||||
.andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:01Z"))
|
||||
|
||||
mockMvc.perform(
|
||||
get("${commentsPath(character.id!!, post.id!!)}/${root.id}/replies")
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].comment").value("reply"))
|
||||
.andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:02Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추가 timezone query는 원댓글과 답글 목록 결과에 영향을 주지 않는다")
|
||||
fun shouldIgnoreTimezoneQueryForRootCommentsAndReplies() {
|
||||
registerMysqlDateFunctions()
|
||||
val character = createCharacter("community-comment-timezone")
|
||||
val post = savePost(character.creatorMember!!, "comment timezone post")
|
||||
val root = saveComment(post, character.creatorMember!!, "root")
|
||||
saveComment(post, character.creatorMember!!, "reply", root)
|
||||
entityManager.flush()
|
||||
|
||||
val rootWithoutTimezone = mockMvc.perform(
|
||||
get(commentsPath(character.id!!, post.id!!))
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn().response.contentAsString
|
||||
val rootWithTimezone = mockMvc.perform(
|
||||
get(commentsPath(character.id!!, post.id!!))
|
||||
.param("timezone", "America/Los_Angeles")
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn().response.contentAsString
|
||||
assertEquals(rootWithoutTimezone, rootWithTimezone)
|
||||
|
||||
val repliesPath = "${commentsPath(character.id!!, post.id!!)}/${root.id}/replies"
|
||||
val repliesWithoutTimezone = mockMvc.perform(
|
||||
get(repliesPath)
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn().response.contentAsString
|
||||
val repliesWithTimezone = mockMvc.perform(
|
||||
get(repliesPath)
|
||||
.param("timezone", "America/Los_Angeles")
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn().response.contentAsString
|
||||
assertEquals(repliesWithoutTimezone, repliesWithTimezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("작성은 target AI를 writer로 사용하고 root와 답글 모두 data null을 반환한다")
|
||||
fun shouldCreateRootAndReplyAsTargetAi() {
|
||||
val character = createCharacter("community-comment-create")
|
||||
val post = savePost(character.creatorMember!!, "comment create post")
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
post(commentsPath(character.id!!, post.id!!))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"comment":"root"}""")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
val root = commentsFor(post.id!!).single()
|
||||
assertEquals(character.creatorMember!!.id, root.member!!.id)
|
||||
|
||||
mockMvc.perform(
|
||||
post(commentsPath(character.id!!, post.id!!))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"comment":"reply", "parentId":${root.id}, "isSecret":false}""")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
val reply = commentsFor(post.id!!).single { it.parent?.id == root.id }
|
||||
assertEquals(character.creatorMember!!.id, reply.member!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("다른 게시글 또는 비활성 parent 답글 작성은 insert 없이 거부한다")
|
||||
fun shouldRejectInvalidReplyParentWithoutInsert() {
|
||||
val character = createCharacter("community-comment-parent")
|
||||
val post = savePost(character.creatorMember!!, "comment parent post")
|
||||
val otherPost = savePost(character.creatorMember!!, "comment other post")
|
||||
val foreignParent = saveComment(otherPost, character.creatorMember!!, "other root")
|
||||
val inactiveParent = saveComment(post, character.creatorMember!!, "inactive root").apply { isActive = false }
|
||||
entityManager.flush()
|
||||
|
||||
val beforeCount = commentsFor(post.id!!).size
|
||||
listOf(foreignParent.id!!, inactiveParent.id!!).forEach { parentId ->
|
||||
mockMvc.perform(
|
||||
post(commentsPath(character.id!!, post.id!!))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"comment":"must not persist", "parentId":$parentId}""")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
assertEquals(beforeCount, commentsFor(post.id!!).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("target AI가 작성하지 않은 활성 댓글 수정은 거부하고 원문을 유지한다")
|
||||
fun shouldRejectFanAuthoredCommentUpdate() {
|
||||
val character = createCharacter("community-comment-update")
|
||||
val post = savePost(character.creatorMember!!, "comment update post")
|
||||
val fanComment = saveComment(post, saveMember("community-comment-update-fan"), "fan comment")
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
put("${commentsPath(character.id!!, post.id!!)}/${fanComment.id}")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"comment":"changed"}""")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
|
||||
assertEquals("fan comment", entityManager.find(CreatorCommunityComment::class.java, fanComment.id).comment)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("target AI가 작성한 활성 댓글 수정은 data null로 완료한다")
|
||||
fun shouldUpdateTargetAiAuthoredComment() {
|
||||
val character = createCharacter("community-comment-update-target")
|
||||
val post = savePost(character.creatorMember!!, "comment update target post")
|
||||
val comment = saveComment(post, character.creatorMember!!, "before")
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
put("${commentsPath(character.id!!, post.id!!)}/${comment.id}")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"comment":"after"}""")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
assertEquals("after", entityManager.find(CreatorCommunityComment::class.java, comment.id).comment)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("text/plain 댓글 작성과 수정은 415로 거부하고 부작용이 없다")
|
||||
fun shouldRejectPlainTextCommentCreateAndUpdateBeforeSideEffects(language: String, message: String) {
|
||||
val character = createCharacter("community-comment-media-type-$language")
|
||||
val post = savePost(character.creatorMember!!, "comment media type post")
|
||||
val comment = saveComment(post, character.creatorMember!!, "before")
|
||||
entityManager.flush()
|
||||
|
||||
val commentsPath = commentsPath(character.id!!, post.id!!)
|
||||
val beforeCount = commentsFor(post.id!!).size
|
||||
mockMvc.perform(
|
||||
post(commentsPath)
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.content("{\"comment\":\"created\"}")
|
||||
.header("Accept-Language", language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isUnsupportedMediaType)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(jsonPath("$.data").doesNotExist())
|
||||
.andExpect(jsonPath("$.errorProperty").doesNotExist())
|
||||
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||
|
||||
mockMvc.perform(
|
||||
put("$commentsPath/${comment.id}")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.content("{\"comment\":\"updated\"}")
|
||||
.header("Accept-Language", language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isUnsupportedMediaType)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(jsonPath("$.data").doesNotExist())
|
||||
.andExpect(jsonPath("$.errorProperty").doesNotExist())
|
||||
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||
|
||||
assertEquals(beforeCount, commentsFor(post.id!!).size)
|
||||
assertEquals("before", entityManager.find(CreatorCommunityComment::class.java, comment.id).comment)
|
||||
verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("삭제는 대상 게시글의 한 row만 비활성화하고 이미 비활성이면 성공 no-op이다")
|
||||
fun shouldSoftDeleteOnlyTargetRowAndIgnoreInactiveComment() {
|
||||
val character = createCharacter("community-comment-delete")
|
||||
val post = savePost(character.creatorMember!!, "comment delete post")
|
||||
val fan = saveMember("community-comment-delete-fan")
|
||||
val root = saveComment(post, fan, "root")
|
||||
val reply = saveComment(post, fan, "reply", root)
|
||||
entityManager.flush()
|
||||
|
||||
val path = "${commentsPath(character.id!!, post.id!!)}/${root.id}"
|
||||
mockMvc.perform(delete(path).with(adminAuthentication()))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertFalse(entityManager.find(CreatorCommunityComment::class.java, root.id).isActive)
|
||||
assertTrue(entityManager.find(CreatorCommunityComment::class.java, reply.id).isActive)
|
||||
|
||||
mockMvc.perform(delete(path).with(adminAuthentication()))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
assertTrue(entityManager.find(CreatorCommunityComment::class.java, reply.id).isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("누락 또는 잘못된 요청은 공통 400 envelope으로 변환한다")
|
||||
fun shouldRejectMissingOrInvalidCommentRequests() {
|
||||
val character = createCharacter("community-comment-request")
|
||||
val post = savePost(character.creatorMember!!, "comment request post")
|
||||
entityManager.flush()
|
||||
|
||||
listOf("{}", "{\"comment\":", "{\"comment\":\"comment\",\"unknown\":true}").forEach { body ->
|
||||
mockMvc.perform(
|
||||
post(commentsPath(character.id!!, post.id!!))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
get(commentsPath(character.id!!, post.id!!))
|
||||
.param("page", "-1")
|
||||
.param("size", "0")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-$name-character",
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
).also {
|
||||
it.creatorMember!!.profileImage = "profile/$name.png"
|
||||
}
|
||||
|
||||
private fun savePost(owner: Member, content: String): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false
|
||||
).apply {
|
||||
member = owner
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveComment(
|
||||
post: CreatorCommunity,
|
||||
writer: Member,
|
||||
comment: String,
|
||||
parent: CreatorCommunityComment? = null
|
||||
): CreatorCommunityComment {
|
||||
return CreatorCommunityComment(comment = comment).apply {
|
||||
creatorCommunity = post
|
||||
member = writer
|
||||
this.parent = parent
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String): Member {
|
||||
return Member(
|
||||
email = "$nickname@example.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = MemberRole.USER
|
||||
).apply(entityManager::persist)
|
||||
}
|
||||
|
||||
private fun commentsFor(postId: Long): List<CreatorCommunityComment> {
|
||||
entityManager.flush()
|
||||
return entityManager.createQuery(
|
||||
"select comment from CreatorCommunityComment comment where comment.creatorCommunity.id = :postId",
|
||||
CreatorCommunityComment::class.java
|
||||
).setParameter("postId", postId).resultList
|
||||
}
|
||||
|
||||
private fun registerMysqlDateFunctions() {
|
||||
entityManager.createNativeQuery(
|
||||
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
|
||||
).executeUpdate()
|
||||
entityManager.createNativeQuery(
|
||||
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
|
||||
).executeUpdate()
|
||||
}
|
||||
|
||||
private fun commentsPath(characterId: Long, postId: Long): String {
|
||||
return "/api/v2/admin/ai-characters/$characterId/community-posts/$postId/comments"
|
||||
}
|
||||
|
||||
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,286 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@MockBean
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
|
||||
@SpyBean
|
||||
private lateinit var creatorCommunityRepository: CreatorCommunityRepository
|
||||
|
||||
@Test
|
||||
@DisplayName("순차 고정 요청에서 세 번째는 허용되고 네 번째는 최대 고정 수로 거부된다")
|
||||
fun shouldAllowThirdFixedPostAndRejectFourthWhenRequestsAreSerialized() {
|
||||
val character = createCharacter("community-fixed-count")
|
||||
val owner = character.creatorMember!!
|
||||
repeat(2) { index -> savePost(owner, "already fixed $index").fix() }
|
||||
val thirdPost = savePost(owner, "third fixed")
|
||||
val fourthPost = savePost(owner, "fourth fixed")
|
||||
entityManager.flush()
|
||||
Mockito.clearInvocations(amazonS3Client, applicationEventPublisher)
|
||||
|
||||
mockMvc.perform(fixedRequest(character.id!!, thirdPost.id!!))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
|
||||
assertTrue(reload(thirdPost.id!!).isFixed)
|
||||
|
||||
mockMvc.perform(fixedRequest(character.id!!, fourthPost.id!!))
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("최대 3개까지 고정 가능합니다."))
|
||||
|
||||
assertFalse(reload(fourthPost.id!!).isFixed)
|
||||
assertEquals(3L, fixedPostCount(owner.id!!))
|
||||
Mockito.verifyNoInteractions(amazonS3Client, applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미 고정된 세 번째 게시글의 재고정은 최대 고정 수를 다시 적용하지 않는다")
|
||||
fun shouldAllowRefixingAlreadyFixedPostAtMaximumCount() {
|
||||
val character = createCharacter("community-refix")
|
||||
val owner = character.creatorMember!!
|
||||
repeat(2) { index -> savePost(owner, "other fixed $index").fix() }
|
||||
val fixedPost = savePost(owner, "already fixed").fix()
|
||||
entityManager.flush()
|
||||
Mockito.clearInvocations(amazonS3Client, applicationEventPublisher)
|
||||
|
||||
mockMvc.perform(fixedRequest(character.id!!, fixedPost.id!!))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
|
||||
val reloadedPost = reload(fixedPost.id!!)
|
||||
assertTrue(reloadedPost.isFixed)
|
||||
assertNotNull(reloadedPost.fixedAt)
|
||||
assertEquals(3L, fixedPostCount(owner.id!!))
|
||||
Mockito.verifyNoInteractions(amazonS3Client, applicationEventPublisher)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("병렬 고정 요청에서도 최대 3개 불변식을 유지한다")
|
||||
fun shouldKeepMaximumThreeFixedPostsWhenRequestsRace() {
|
||||
val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
val fixture = transactionTemplate.execute {
|
||||
val character = createCharacter("community-fixed-race")
|
||||
val owner = character.creatorMember!!
|
||||
repeat(2) { index -> savePost(owner, "race fixed $index").fix() }
|
||||
val firstPost = savePost(owner, "race first")
|
||||
val secondPost = savePost(owner, "race second")
|
||||
entityManager.flush()
|
||||
FixedRaceFixture(character.id!!, owner.id!!, firstPost.id!!, secondPost.id!!)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
|
||||
val firstCountIntercepted = AtomicBoolean(false)
|
||||
val firstCountRead = CountDownLatch(1)
|
||||
val releaseFirstCount = CountDownLatch(1)
|
||||
Mockito.doAnswer { invocation ->
|
||||
if (invocation.arguments[0] == fixture.ownerId && firstCountIntercepted.compareAndSet(false, true)) {
|
||||
firstCountRead.countDown()
|
||||
releaseFirstCount.await(300, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
fixedPostCount(fixture.ownerId)
|
||||
}.`when`(creatorCommunityRepository).countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(fixture.ownerId)
|
||||
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val first = executor.submit<Int> {
|
||||
mockMvc.perform(fixedRequest(fixture.characterId, fixture.firstPostId))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
assertTrue(firstCountRead.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val second = executor.submit<Int> {
|
||||
mockMvc.perform(fixedRequest(fixture.characterId, fixture.secondPostId))
|
||||
.andReturn()
|
||||
.response
|
||||
.status
|
||||
}
|
||||
|
||||
releaseFirstCount.countDown()
|
||||
val statuses = listOf(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf(200, 400), statuses.sorted())
|
||||
} finally {
|
||||
releaseFirstCount.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
assertEquals(3L, transactionTemplate.execute { fixedPostCount(fixture.ownerId) })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid target와 cross-owner 고정 요청은 DB, S3, event 변경 없이 거부된다")
|
||||
fun shouldRejectInvalidTargetAndCrossOwnerFixedRequestsWithoutSideEffects() {
|
||||
val character = createCharacter("community-fixed-owner")
|
||||
val otherCharacter = createCharacter("community-fixed-other-owner")
|
||||
val ownPost = savePost(character.creatorMember!!, "own", imagePath = "creator_community/own.png")
|
||||
val foreignPost = savePost(otherCharacter.creatorMember!!, "foreign", imagePath = "creator_community/foreign.png")
|
||||
entityManager.flush()
|
||||
val beforeCount = postCount()
|
||||
Mockito.clearInvocations(amazonS3Client, applicationEventPublisher)
|
||||
|
||||
mockMvc.perform(fixedRequest(999_999L, ownPost.id!!, pngFile()))
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
|
||||
|
||||
mockMvc.perform(fixedRequest(character.id!!, foreignPost.id!!, pngFile()))
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
|
||||
|
||||
assertEquals(beforeCount, postCount())
|
||||
assertFalse(reload(ownPost.id!!).isFixed)
|
||||
assertFalse(reload(foreignPost.id!!).isFixed)
|
||||
assertEquals("creator_community/own.png", reload(ownPost.id!!).imagePath)
|
||||
assertEquals("creator_community/foreign.png", reload(foreignPost.id!!).imagePath)
|
||||
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
Mockito.verifyNoInteractions(applicationEventPublisher)
|
||||
}
|
||||
|
||||
private fun fixedRequest(
|
||||
characterId: Long,
|
||||
postId: Long,
|
||||
postImage: MockMultipartFile? = null
|
||||
) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply {
|
||||
file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(mapOf("isFixed" to true))
|
||||
)
|
||||
)
|
||||
postImage?.let(::file)
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun pngFile() = MockMultipartFile(
|
||||
"postImage",
|
||||
"post.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
)
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun savePost(owner: Member, content: String, imagePath: String? = null): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
imagePath = imagePath
|
||||
).apply {
|
||||
member = owner
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorCommunity.fix(): CreatorCommunity {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
return this
|
||||
}
|
||||
|
||||
private fun reload(postId: Long): CreatorCommunity {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
return entityManager.find(CreatorCommunity::class.java, postId)
|
||||
}
|
||||
|
||||
private fun fixedPostCount(ownerId: Long): Long = entityManager.createQuery(
|
||||
"select count(p) from CreatorCommunity p where p.member.id = :ownerId and p.isActive = true and p.isFixed = true",
|
||||
java.lang.Long::class.java
|
||||
).setParameter("ownerId", ownerId).singleResult.toLong()
|
||||
|
||||
private fun postCount(): Long = entityManager.createQuery(
|
||||
"select count(p) from CreatorCommunity p",
|
||||
java.lang.Long::class.java
|
||||
).singleResult.toLong()
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
|
||||
private data class FixedRaceFixture(
|
||||
val characterId: Long,
|
||||
val ownerId: Long,
|
||||
val firstPostId: Long,
|
||||
val secondPostId: Long
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
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.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.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.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.ResultActions
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||
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 AiCharacterAdminCommunityPostContractTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("잘못된 target과 post는 공통 400 ApiResponse를 요청 언어로 반환한다")
|
||||
fun shouldReturnLocalizedInvalidRequestForInvalidTargetAndPost(language: String, message: String) {
|
||||
val character = createCharacter("community-contract-invalid")
|
||||
val post = savePost(character.creatorMember!!, "unchanged")
|
||||
entityManager.flush()
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/999999/community-posts")
|
||||
.header("Accept-Language", language)
|
||||
.with(adminAuthentication())
|
||||
).andExpectApiError(400, message)
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(character.id!!, 999998L, mapOf("content" to "unexpected"))
|
||||
.header("Accept-Language", language)
|
||||
).andExpectApiError(400, message)
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
assertEquals("unchanged", reload(post.id!!).content)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,유료 게시글 등록을 위해서는 이미지가 필요합니다.,최대 3개까지 고정 가능합니다.",
|
||||
"en,An image is required to post paid content.,You can pin up to 3 posts.",
|
||||
"ja,有料投稿を登録するには画像が必要です。,固定できる投稿は最大3件までです。"
|
||||
)
|
||||
@DisplayName("media 검증과 최대 고정 수 오류는 legacy message를 요청 언어로 반환한다")
|
||||
fun shouldReturnLocalizedLegacyMediaAndFixedCountErrors(
|
||||
language: String,
|
||||
mediaMessage: String,
|
||||
fixedCountMessage: String
|
||||
) {
|
||||
val character = createCharacter("community-contract-legacy-errors")
|
||||
val owner = character.creatorMember!!
|
||||
repeat(3) { index ->
|
||||
savePost(owner, "fixed $index").apply {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
val candidate = savePost(owner, "candidate", imagePath = "creator_community/original.png")
|
||||
entityManager.flush()
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(character.id!!, price = 10)
|
||||
.header("Accept-Language", language)
|
||||
).andExpectApiError(400, mediaMessage)
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
candidate.id!!,
|
||||
mapOf("isFixed" to true),
|
||||
pngFile()
|
||||
).header("Accept-Language", language)
|
||||
).andExpectApiError(400, fixedCountMessage)
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
assertEquals("creator_community/original.png", reload(candidate.id!!).imagePath)
|
||||
assertEquals(false, reload(candidate.id!!).isFixed)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("필수 multipart request part 누락은 공통 400 ApiResponse를 요청 언어로 반환한다")
|
||||
fun shouldReturnLocalizedInvalidRequestForMissingMultipartRequestPart(language: String, message: String) {
|
||||
val createResult = mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters/1/community-posts")
|
||||
.header("Accept-Language", language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
createResult.andExpectApiError(400, message)
|
||||
assertEquals(MissingServletRequestPartException::class.java, createResult.andReturn().resolvedException?.javaClass)
|
||||
|
||||
val updateResult = mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/community-posts/1")
|
||||
.header("Accept-Language", language)
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
updateResult.andExpectApiError(400, message)
|
||||
assertEquals(MissingServletRequestPartException::class.java, updateResult.andReturn().resolvedException?.javaClass)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("HUMAN target과 다른 AI 캐릭터 게시글 mutation은 DB와 S3 변경 없이 거부한다")
|
||||
fun shouldRejectHumanTargetAndCrossCharacterMutationWithoutDatabaseOrS3SideEffects() {
|
||||
val humanCharacter = createCharacter("community-contract-human")
|
||||
humanCharacter.creatorMember!!.memberKind = MemberKind.HUMAN
|
||||
val character = createCharacter("community-contract-owner")
|
||||
val otherCharacter = createCharacter("community-contract-other")
|
||||
val foreignPost = savePost(
|
||||
otherCharacter.creatorMember!!,
|
||||
"foreign",
|
||||
imagePath = "creator_community/foreign.png"
|
||||
)
|
||||
entityManager.flush()
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(humanCharacter.id!!, postImage = pngFile())
|
||||
.header("Accept-Language", "en")
|
||||
).andExpectApiError(400, "Invalid request.")
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
foreignPost.id!!,
|
||||
mapOf("content" to "unexpected"),
|
||||
pngFile()
|
||||
).header("Accept-Language", "en")
|
||||
).andExpectApiError(400, "Invalid request.")
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
assertEquals("foreign", reload(foreignPost.id!!).content)
|
||||
assertEquals("creator_community/foreign.png", reload(foreignPost.id!!).imagePath)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) {
|
||||
andExpect(status().`is`(httpStatus))
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||
}
|
||||
|
||||
private fun createRequest(
|
||||
characterId: Long,
|
||||
price: Int = 0,
|
||||
postImage: MockMultipartFile? = null
|
||||
) = multipart("/api/v2/admin/ai-characters/$characterId/community-posts").apply {
|
||||
file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(
|
||||
mapOf(
|
||||
"content" to "community post",
|
||||
"isCommentAvailable" to true,
|
||||
"isAdult" to false,
|
||||
"price" to price
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
postImage?.let(::file)
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun updateRequest(
|
||||
characterId: Long,
|
||||
postId: Long,
|
||||
request: Map<String, Any?>,
|
||||
postImage: MockMultipartFile? = null
|
||||
) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply {
|
||||
file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(request)
|
||||
)
|
||||
)
|
||||
postImage?.let(::file)
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun pngFile() = MockMultipartFile(
|
||||
"postImage",
|
||||
"post.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
)
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun savePost(owner: Member, content: String, imagePath: String? = null): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
imagePath = imagePath
|
||||
).apply {
|
||||
member = owner
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload(postId: Long): CreatorCommunity {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
return entityManager.find(CreatorCommunity::class.java, postId)
|
||||
}
|
||||
|
||||
private fun countPosts(): Long = entityManager.createQuery(
|
||||
"select count(p) from CreatorCommunity p",
|
||||
java.lang.Long::class.java
|
||||
).singleResult.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,426 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.CsvSource
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.mock.web.MockPart
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
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 AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@Test
|
||||
@DisplayName("정상 무료 게시글 생성은 target 소유자를 작성자로 사용하고 data null을 반환한다")
|
||||
fun shouldCreateFreePostForTargetOwnerAndReturnNullData() {
|
||||
val character = createCharacter("community-create-free")
|
||||
val otherCharacter = createCharacter("community-create-other")
|
||||
val ownerId = character.creatorMember!!.id
|
||||
val content = "free post for target owner"
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(character.id!!, content = content)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.message").value(null as String?))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val post = findPost(content)
|
||||
assertNotNull(post)
|
||||
assertEquals(ownerId, post!!.member!!.id)
|
||||
assertTrue(post.member!!.id != otherCharacter.creatorMember!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성 price 생략은 기본값 0으로 게시글을 생성한다")
|
||||
fun shouldCreatePostWithDefaultPriceWhenPriceIsOmitted() {
|
||||
val character = createCharacter("community-create-default-price")
|
||||
val content = "default price post"
|
||||
|
||||
mockMvc.perform(
|
||||
createRawRequest(
|
||||
character.id!!,
|
||||
"""{"content":"$content","isCommentAvailable":true,"isAdult":false}"""
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(0, findPost(content)!!.price)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미지와 오디오가 있는 게시글 생성은 legacy media 업로드와 side effect를 사용한다")
|
||||
fun shouldCreatePostWithImageAndAudioThroughLegacyMediaPath() {
|
||||
val character = createCharacter("community-create-media")
|
||||
val content = "media post"
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(URL("https://test.cloudfront.net/uploaded"))
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(
|
||||
character.id!!,
|
||||
content = content,
|
||||
postImage = MockMultipartFile(
|
||||
"postImage",
|
||||
"post.png",
|
||||
"image/png",
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
),
|
||||
audioFile = MockMultipartFile("audioFile", "post.m4a", "audio/mp4", byteArrayOf(4, 5, 6))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val post = findPost(content)!!
|
||||
assertNotNull(post.imagePath)
|
||||
assertNotNull(post.audioPath)
|
||||
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("유료 게시글의 이미지 누락은 legacy 오류 메시지로 400을 반환한다")
|
||||
fun shouldRejectPaidPostWithoutImageWithLegacyErrorMessage() {
|
||||
val character = createCharacter("community-create-paid-without-image")
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(character.id!!, price = 10)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("An image is required to post paid content."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오디오 게시글의 이미지 누락은 legacy 오류 메시지로 400을 반환한다")
|
||||
fun shouldRejectAudioPostWithoutImageWithLegacyErrorMessage() {
|
||||
val character = createCharacter("community-create-audio-without-image")
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(
|
||||
character.id!!,
|
||||
audioFile = MockMultipartFile("audioFile", "post.m4a", "audio/mp4", byteArrayOf(1))
|
||||
)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("An image is required to upload audio."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 target은 게시글 생성과 모든 side effect 전에 400으로 거부한다")
|
||||
fun shouldRejectInactiveTargetBeforePersistenceAndSideEffects() {
|
||||
val character = createCharacter("community-create-inactive")
|
||||
character.isActive = false
|
||||
entityManager.flush()
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(createRequest(character.id!!))
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("필수 request part 누락은 common.error.invalid_request 메시지로 400을 반환한다")
|
||||
fun shouldRejectMissingRequestPart() {
|
||||
val character = createCharacter("community-create-missing-request")
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성 multipart의 정의되지 않은 part는 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectUndefinedMultipartPartBeforeSideEffects() {
|
||||
val character = createCharacter("community-create-undefined-part")
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(
|
||||
character.id!!,
|
||||
content = "undefined create part",
|
||||
extraFile = MockMultipartFile("unexpected", "unexpected.txt", MediaType.TEXT_PLAIN_VALUE, byteArrayOf(1))
|
||||
).header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("생성 multipart의 filename 없는 정의되지 않은 part는 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
|
||||
val character = createCharacter("community-create-filename-less-part-$language")
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRequest(
|
||||
character.id!!,
|
||||
content = "filename less create part $language",
|
||||
extraPart = MockPart("unexpected", "unexpected".toByteArray())
|
||||
)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed, 필수 field 누락, 미지 field 생성 request는 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectInvalidCreateJsonWithoutSideEffects() {
|
||||
val character = createCharacter("community-create-invalid-json")
|
||||
|
||||
listOf(
|
||||
"{",
|
||||
"""{"isCommentAvailable":true,"isAdult":false,"price":0}""",
|
||||
"""{"content":"invalid json","isCommentAvailable":true,"isAdult":false,"price":0,"unexpected":true}"""
|
||||
).forEach { requestJson ->
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRawRequest(character.id!!, requestJson)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
value = [
|
||||
"text/plain,ko,잘못된 요청입니다.",
|
||||
"text/plain,en,Invalid request.",
|
||||
"text/plain,ja,無効なリクエストです。",
|
||||
"<missing>,ko,잘못된 요청입니다.",
|
||||
"<missing>,en,Invalid request.",
|
||||
"<missing>,ja,無効なリクエストです。"
|
||||
],
|
||||
nullValues = ["<missing>"]
|
||||
)
|
||||
@DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
|
||||
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
|
||||
requestContentType: String?,
|
||||
language: String,
|
||||
message: String
|
||||
) {
|
||||
val character = createCharacter("community-create-request-media-type-$language")
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRawRequest(
|
||||
character.id!!,
|
||||
"""{"content":"rejected media type","isCommentAvailable":true,"isAdult":false,"price":0}""",
|
||||
requestContentType
|
||||
).header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
)
|
||||
.andExpect(status().isUnsupportedMediaType)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(
|
||||
strings = [
|
||||
"missing-isCommentAvailable",
|
||||
"null-isCommentAvailable",
|
||||
"missing-isAdult",
|
||||
"null-isAdult",
|
||||
"null-price"
|
||||
]
|
||||
)
|
||||
@DisplayName("생성 primitive required/null 위반은 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectMissingOrNullPrimitiveCreateFieldsWithoutSideEffects(case: String) {
|
||||
val character = createCharacter("community-create-primitive-$case")
|
||||
val requestJson = when (case) {
|
||||
"missing-isCommentAvailable" -> """{"content":"$case","isAdult":false,"price":0}"""
|
||||
"null-isCommentAvailable" -> """{"content":"$case","isCommentAvailable":null,"isAdult":false,"price":0}"""
|
||||
"missing-isAdult" -> """{"content":"$case","isCommentAvailable":true,"price":0}"""
|
||||
"null-isAdult" -> """{"content":"$case","isCommentAvailable":true,"isAdult":null,"price":0}"""
|
||||
"null-price" -> """{"content":"$case","isCommentAvailable":true,"isAdult":false,"price":null}"""
|
||||
else -> error("unknown case")
|
||||
}
|
||||
val beforeCount = countPosts()
|
||||
|
||||
mockMvc.perform(
|
||||
createRawRequest(character.id!!, requestJson)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
private fun createRequest(
|
||||
characterId: Long,
|
||||
content: String = "community post",
|
||||
price: Int = 0,
|
||||
postImage: MockMultipartFile? = null,
|
||||
audioFile: MockMultipartFile? = null,
|
||||
extraFile: MockMultipartFile? = null,
|
||||
extraPart: MockPart? = null
|
||||
) = multipart("/api/v2/admin/ai-characters/$characterId/community-posts").apply {
|
||||
file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(
|
||||
mapOf(
|
||||
"content" to content,
|
||||
"isCommentAvailable" to true,
|
||||
"isAdult" to false,
|
||||
"price" to price
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
postImage?.let(::file)
|
||||
audioFile?.let(::file)
|
||||
extraFile?.let(::file)
|
||||
extraPart?.let { part(it) }
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun createRawRequest(
|
||||
characterId: Long,
|
||||
requestJson: String,
|
||||
requestContentType: String? = MediaType.APPLICATION_JSON_VALUE
|
||||
) = multipart("/api/v2/admin/ai-characters/$characterId/community-posts")
|
||||
.file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
requestContentType,
|
||||
requestJson.toByteArray()
|
||||
)
|
||||
)
|
||||
.with(adminAuthentication())
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun findPost(content: String): CreatorCommunity? = entityManager.createQuery(
|
||||
"select p from CreatorCommunity p where p.content = :content",
|
||||
CreatorCommunity::class.java
|
||||
).setParameter("content", content).resultList.firstOrNull()
|
||||
|
||||
private fun countPosts(): Long = entityManager.createQuery(
|
||||
"select count(p) from CreatorCommunity p",
|
||||
java.lang.Long::class.java
|
||||
).singleResult.toLong()
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
kr.co.vividnext.sodalive.member.Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
|
||||
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.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class AiCharacterAdminCommunityPostQueryTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 목록은 timezone 없이 target 소유 활성 게시글을 pagination wrapper로 반환한다")
|
||||
fun shouldReturnOnlyActiveOwnerPostsWithLegacyShapeAndOwnerAccess() {
|
||||
val character = createCharacter("community-list-character")
|
||||
val owner = character.creatorMember!!
|
||||
owner.profileImage = "profiles/community-list.png"
|
||||
val otherCharacter = createCharacter("community-list-other-character")
|
||||
val normal = savePost(owner = owner, content = "normal post")
|
||||
val fixedOld = savePost(owner = owner, content = "fixed old")
|
||||
val fixedNew = savePost(
|
||||
owner = owner,
|
||||
content = "full paid owner content",
|
||||
price = 100,
|
||||
isAdult = true,
|
||||
audioPath = "private/community-owner.m4a",
|
||||
imagePath = "community/owner.png"
|
||||
)
|
||||
savePost(owner = owner, content = "inactive post", isActive = false)
|
||||
savePost(owner = otherCharacter.creatorMember!!, content = "foreign post")
|
||||
fixedOld.isFixed = true
|
||||
fixedOld.fixedAt = LocalDateTime.of(2026, 7, 27, 9, 0)
|
||||
fixedNew.isFixed = true
|
||||
fixedNew.fixedAt = LocalDateTime.of(2026, 7, 28, 9, 0)
|
||||
entityManager.persist(
|
||||
CreatorCommunityLike().apply {
|
||||
member = owner
|
||||
creatorCommunity = fixedNew
|
||||
}
|
||||
)
|
||||
entityManager.persist(
|
||||
CreatorCommunityComment("owner comment").apply {
|
||||
member = owner
|
||||
creatorCommunity = fixedNew
|
||||
}
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
Mockito.`when`(audioContentCloudFront.generateSignedURL("private/community-owner.m4a", 1_800_000L))
|
||||
.thenReturn("https://signed.example.com/community-owner.m4a?Expires=1")
|
||||
|
||||
val response = mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.param("page", "0")
|
||||
.param("size", "2")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andReturn()
|
||||
|
||||
val data = objectMapper.readTree(response.response.contentAsString).path("data")
|
||||
val items = data.path("items")
|
||||
val item = items.path(0)
|
||||
assertEquals(3, data.path("totalCount").asLong())
|
||||
assertEquals(0, data.path("page").asInt())
|
||||
assertEquals(2, data.path("size").asInt())
|
||||
assertTrue(data.path("hasNext").asBoolean())
|
||||
assertTrue(items.isArray)
|
||||
assertEquals(listOf(fixedNew.id, fixedOld.id), items.map { it.path("postId").asLong() })
|
||||
assertEquals(
|
||||
setOf(
|
||||
"postId",
|
||||
"creatorId",
|
||||
"creatorNickname",
|
||||
"creatorProfileUrl",
|
||||
"imageUrl",
|
||||
"audioUrl",
|
||||
"content",
|
||||
"price",
|
||||
"date",
|
||||
"dateUtc",
|
||||
"isCommentAvailable",
|
||||
"isAdult",
|
||||
"isFixed",
|
||||
"isLike",
|
||||
"existOrdered",
|
||||
"likeCount",
|
||||
"commentCount",
|
||||
"firstComment"
|
||||
),
|
||||
item.fieldNames().asSequence().toSet()
|
||||
)
|
||||
assertEquals("full paid owner content", item.path("content").asText())
|
||||
assertEquals("https://test.cloudfront.net/profiles/community-list.png", item.path("creatorProfileUrl").asText())
|
||||
assertEquals("https://test.cloudfront.net/community/owner.png", item.path("imageUrl").asText())
|
||||
assertEquals("https://signed.example.com/community-owner.m4a?Expires=1", item.path("audioUrl").asText())
|
||||
assertTrue(item.path("isAdult").asBoolean())
|
||||
assertTrue(item.path("existOrdered").asBoolean())
|
||||
assertTrue(!item.path("isLike").asBoolean())
|
||||
assertEquals(1, item.path("likeCount").asInt())
|
||||
assertEquals(1, item.path("commentCount").asInt())
|
||||
assertTrue(item.path("firstComment").isNull)
|
||||
Mockito.verify(audioContentCloudFront).generateSignedURL("private/community-owner.m4a", 1_800_000L)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.param("page", "1")
|
||||
.param("size", "2")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(3))
|
||||
.andExpect(jsonPath("$.data.page").value(1))
|
||||
.andExpect(jsonPath("$.data.size").value(2))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].postId").value(normal.id))
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.param("page", "2")
|
||||
.param("size", "2")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(3))
|
||||
.andExpect(jsonPath("$.data.page").value(2))
|
||||
.andExpect(jsonPath("$.data.size").value(2))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 AI 캐릭터 target의 게시글 목록을 400으로 거부한다")
|
||||
fun shouldRejectInactiveTarget() {
|
||||
val character = createCharacter("inactive-community-target")
|
||||
character.isActive = false
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.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("게시글 목록은 음수 page와 1 미만 size만 400으로 거부하고 size 51은 허용한다")
|
||||
fun shouldRejectInvalidPagination() {
|
||||
val character = createCharacter("invalid-community-pagination")
|
||||
savePost(character.creatorMember!!, "size 51 allowed")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf("-1" to "20", "0" to "0").forEach { (page, size) ->
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.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()))
|
||||
}
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.param("page", "0")
|
||||
.param("size", "51")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(51))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 목록은 page와 size 기본값으로 timezone 없이 조회된다")
|
||||
fun shouldUseDefaultPaginationWithoutTimezone() {
|
||||
val character = createCharacter("required-community-timezone")
|
||||
savePost(character.creatorMember!!, "default pagination")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/ai-characters/${character.id}/community-posts")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||
}
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun savePost(
|
||||
owner: Member,
|
||||
content: String,
|
||||
price: Int = 0,
|
||||
isAdult: Boolean = false,
|
||||
isActive: Boolean = true,
|
||||
audioPath: String? = null,
|
||||
imagePath: String? = null
|
||||
): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = price,
|
||||
isCommentAvailable = true,
|
||||
isAdult = isAdult,
|
||||
audioPath = audioPath,
|
||||
imagePath = imagePath,
|
||||
isActive = isActive
|
||||
).apply {
|
||||
member = owner
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun adminAuthentication() = authentication(
|
||||
UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(
|
||||
Member(
|
||||
email = "admin@example.com",
|
||||
password = "password",
|
||||
nickname = "admin",
|
||||
role = MemberRole.ADMIN
|
||||
)
|
||||
),
|
||||
"token",
|
||||
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
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.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.mock.web.MockPart
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.net.URL
|
||||
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 AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val chatCharacterService: ChatCharacterService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var amazonS3Client: AmazonS3Client
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 수정은 content, 댓글 허용, 성인 여부를 legacy 수정 경로로 반영한다")
|
||||
fun shouldUpdateContentCommentAvailabilityAndAdultStatus() {
|
||||
val character = createCharacter("community-update-fields")
|
||||
val post = savePost(character.creatorMember!!, "before", isCommentAvailable = true, isAdult = false).apply {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
}
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
post.id!!,
|
||||
mapOf(
|
||||
"content" to "after",
|
||||
"isCommentAvailable" to false,
|
||||
"isAdult" to true
|
||||
)
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val updated = reload(post.id!!)
|
||||
assertEquals("after", updated.content)
|
||||
assertFalse(updated.isCommentAvailable)
|
||||
assertTrue(updated.isAdult)
|
||||
assertTrue(updated.isFixed)
|
||||
assertNotNull(updated.fixedAt)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 수정은 새 postImage를 legacy 업로드 경로로 교체한다")
|
||||
fun shouldReplacePostImageThroughLegacyUploadPath() {
|
||||
val character = createCharacter("community-update-image")
|
||||
val post = savePost(character.creatorMember!!, "image", imagePath = "creator_community/old.png")
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(URL("https://test.cloudfront.net/updated"))
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
post.id!!,
|
||||
mapOf(),
|
||||
MockMultipartFile(
|
||||
"postImage",
|
||||
"updated.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
)
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertTrue(reload(post.id!!).imagePath!!.startsWith("creator_community/${post.id}/"))
|
||||
Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최대 고정 수 초과는 postImage 업로드와 imagePath 변경 전에 거부한다")
|
||||
fun shouldRejectFixedPostLimitBeforeUploadingPostImage() {
|
||||
val character = createCharacter("community-update-fixed-limit")
|
||||
val owner = character.creatorMember!!
|
||||
repeat(3) { index ->
|
||||
savePost(owner, "already fixed $index").apply {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
val post = savePost(owner, "unfixed", imagePath = "creator_community/original.png")
|
||||
val originalImagePath = post.imagePath
|
||||
entityManager.flush()
|
||||
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(URL("https://test.cloudfront.net/should-not-upload"))
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
post.id!!,
|
||||
mapOf("isFixed" to true),
|
||||
MockMultipartFile(
|
||||
"postImage",
|
||||
"updated.png",
|
||||
MediaType.IMAGE_PNG_VALUE,
|
||||
byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
)
|
||||
)
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("최대 3개까지 고정 가능합니다."))
|
||||
|
||||
assertEquals(originalImagePath, reload(post.id!!).imagePath)
|
||||
Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("게시글 수정은 isFixed true와 false를 legacy 고정 경로로 반영한다")
|
||||
fun shouldFixAndUnfixPost() {
|
||||
val character = createCharacter("community-update-fixed")
|
||||
val post = savePost(character.creatorMember!!, "fixed")
|
||||
|
||||
mockMvc.perform(updateRequest(character.id!!, post.id!!, mapOf("isFixed" to true)))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
val fixed = reload(post.id!!)
|
||||
assertTrue(fixed.isFixed)
|
||||
assertNotNull(fixed.fixedAt)
|
||||
|
||||
mockMvc.perform(updateRequest(character.id!!, post.id!!, mapOf("isFixed" to false)))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
val unfixed = reload(post.id!!)
|
||||
assertFalse(unfixed.isFixed)
|
||||
assertNull(unfixed.fixedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("soft delete는 같은 transaction에서 고정 상태와 fixedAt을 함께 해제한다")
|
||||
fun shouldClearFixedStateAndFixedAtWhenSoftDeletingPost() {
|
||||
val character = createCharacter("community-update-soft-delete")
|
||||
val post = savePost(character.creatorMember!!, "delete").apply {
|
||||
isFixed = true
|
||||
fixedAt = LocalDateTime.now()
|
||||
}
|
||||
entityManager.flush()
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(character.id!!, post.id!!, mapOf("isActive" to false, "isFixed" to true))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val deleted = reload(post.id!!)
|
||||
assertFalse(deleted.isActive)
|
||||
assertFalse(deleted.isFixed)
|
||||
assertNull(deleted.fixedAt)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing target와 post, cross-owner, inactive target와 post는 DB와 S3 변경 없이 거부한다")
|
||||
fun shouldRejectInvalidMutationTargetsWithoutDatabaseOrS3SideEffects() {
|
||||
val character = createCharacter("community-update-invalid")
|
||||
val otherCharacter = createCharacter("community-update-other")
|
||||
val ownPost = savePost(character.creatorMember!!, "own post")
|
||||
val foreignPost = savePost(otherCharacter.creatorMember!!, "foreign post")
|
||||
val inactivePost = savePost(character.creatorMember!!, "inactive post", isActive = false)
|
||||
val inactiveCharacter = createCharacter("community-update-inactive-target").apply { isActive = false }
|
||||
entityManager.flush()
|
||||
val beforeCount = countPosts()
|
||||
|
||||
listOf(
|
||||
updateRequest(999_999L, ownPost.id!!, mapOf("content" to "missing target")),
|
||||
updateRequest(inactiveCharacter.id!!, ownPost.id!!, mapOf("content" to "inactive target")),
|
||||
updateRequest(character.id!!, 999_998L, mapOf("content" to "missing post")),
|
||||
updateRequest(character.id!!, foreignPost.id!!, mapOf("content" to "cross owner")),
|
||||
updateRequest(character.id!!, inactivePost.id!!, mapOf("content" to "inactive post"))
|
||||
).forEach { request ->
|
||||
mockMvc.perform(request)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
}
|
||||
|
||||
assertEquals(beforeCount, countPosts())
|
||||
assertEquals("own post", reload(ownPost.id!!).content)
|
||||
assertEquals("foreign post", reload(foreignPost.id!!).content)
|
||||
assertEquals("inactive post", reload(inactivePost.id!!).content)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("필수 request part 누락은 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectMissingRequestPartWithoutSideEffects() {
|
||||
val character = createCharacter("community-update-missing-request")
|
||||
val post = savePost(character.creatorMember!!, "unchanged")
|
||||
|
||||
mockMvc.perform(
|
||||
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/community-posts/${post.id}")
|
||||
.with(adminAuthentication())
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("잘못된 요청입니다."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals("unchanged", reload(post.id!!).content)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource("unexpected,unexpected.txt", "audioFile,post.m4a")
|
||||
@DisplayName("수정 multipart의 정의되지 않은 part와 audioFile은 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectUndefinedMultipartPartBeforeSideEffects(partName: String, fileName: String) {
|
||||
val character = createCharacter("community-update-undefined-part-$partName")
|
||||
val post = savePost(character.creatorMember!!, "unchanged", imagePath = "creator_community/original.png")
|
||||
val originalImagePath = post.imagePath
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
post.id!!,
|
||||
mapOf("content" to "changed"),
|
||||
extraFile = MockMultipartFile(partName, fileName, MediaType.TEXT_PLAIN_VALUE, byteArrayOf(1))
|
||||
).header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val unchanged = reload(post.id!!)
|
||||
assertEquals("unchanged", unchanged.content)
|
||||
assertEquals(originalImagePath, unchanged.imagePath)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"ko,잘못된 요청입니다.",
|
||||
"en,Invalid request.",
|
||||
"ja,無効なリクエストです。"
|
||||
)
|
||||
@DisplayName("수정 multipart의 filename 없는 정의되지 않은 part는 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) {
|
||||
val character = createCharacter("community-update-filename-less-part-$language")
|
||||
val post = savePost(character.creatorMember!!, "unchanged", imagePath = "creator_community/original.png")
|
||||
val originalImagePath = post.imagePath
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
updateRequest(
|
||||
character.id!!,
|
||||
post.id!!,
|
||||
mapOf("content" to "changed"),
|
||||
extraPart = MockPart("unexpected", "unexpected".toByteArray())
|
||||
)
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val unchanged = reload(post.id!!)
|
||||
assertEquals("unchanged", unchanged.content)
|
||||
assertEquals(originalImagePath, unchanged.imagePath)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed, 미지 field 수정 request는 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectInvalidUpdateJsonWithoutSideEffects() {
|
||||
val character = createCharacter("community-update-invalid-json")
|
||||
val post = savePost(character.creatorMember!!, "unchanged")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
listOf(
|
||||
"{",
|
||||
"""{"content":"changed","isCommentAvailable":false,"isAdult":true,"unexpected":true}"""
|
||||
).forEach { requestJson ->
|
||||
mockMvc.perform(
|
||||
updateRawRequest(character.id!!, post.id!!, requestJson)
|
||||
.header("Accept-Language", "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
assertEquals("unchanged", reload(post.id!!).content)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
value = [
|
||||
"text/plain,ko,잘못된 요청입니다.",
|
||||
"text/plain,en,Invalid request.",
|
||||
"text/plain,ja,無効なリクエストです。",
|
||||
"<missing>,ko,잘못된 요청입니다.",
|
||||
"<missing>,en,Invalid request.",
|
||||
"<missing>,ja,無効なリクエストです。"
|
||||
],
|
||||
nullValues = ["<missing>"]
|
||||
)
|
||||
@DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다")
|
||||
fun shouldRejectNonJsonRequestPartBeforeSideEffects(
|
||||
requestContentType: String?,
|
||||
language: String,
|
||||
message: String
|
||||
) {
|
||||
val character = createCharacter("community-update-request-media-type-$language")
|
||||
val post = savePost(character.creatorMember!!, "unchanged")
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
updateRawRequest(
|
||||
character.id!!,
|
||||
post.id!!,
|
||||
"""{"content":"rejected media type"}""",
|
||||
requestContentType
|
||||
).header(HttpHeaders.ACCEPT_LANGUAGE, language)
|
||||
)
|
||||
.andExpect(status().isUnsupportedMediaType)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value(message))
|
||||
.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||
|
||||
assertEquals("unchanged", reload(post.id!!).content)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정 isFixed null은 DB와 S3 변경 없이 400으로 거부한다")
|
||||
fun shouldRejectNullIsFixedUpdateWithoutSideEffects() {
|
||||
val character = createCharacter("community-update-null-fixed")
|
||||
val post = savePost(character.creatorMember!!, "unchanged", imagePath = "creator_community/original.png")
|
||||
val originalImagePath = post.imagePath
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
updateRawRequest(character.id!!, post.id!!, """{"isFixed":null}""")
|
||||
.header("Accept-Language", "en")
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||
.andExpect(jsonPath("$.data").value(null as String?))
|
||||
|
||||
val unchanged = reload(post.id!!)
|
||||
assertEquals("unchanged", unchanged.content)
|
||||
assertEquals(originalImagePath, unchanged.imagePath)
|
||||
assertFalse(unchanged.isFixed)
|
||||
Mockito.verifyNoInteractions(amazonS3Client)
|
||||
}
|
||||
|
||||
private fun updateRequest(
|
||||
characterId: Long,
|
||||
postId: Long,
|
||||
request: Map<String, Any?>,
|
||||
postImage: MockMultipartFile? = null,
|
||||
extraFile: MockMultipartFile? = null,
|
||||
extraPart: MockPart? = null
|
||||
) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply {
|
||||
file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
objectMapper.writeValueAsBytes(request)
|
||||
)
|
||||
)
|
||||
postImage?.let(::file)
|
||||
extraFile?.let(::file)
|
||||
extraPart?.let { part(it) }
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun updateRawRequest(
|
||||
characterId: Long,
|
||||
postId: Long,
|
||||
requestJson: String,
|
||||
requestContentType: String? = MediaType.APPLICATION_JSON_VALUE
|
||||
) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply {
|
||||
file(
|
||||
MockMultipartFile(
|
||||
"request",
|
||||
"request.json",
|
||||
requestContentType,
|
||||
requestJson.toByteArray()
|
||||
)
|
||||
)
|
||||
}.with(adminAuthentication())
|
||||
|
||||
private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = name,
|
||||
name = name,
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
)
|
||||
|
||||
private fun savePost(
|
||||
owner: Member,
|
||||
content: String,
|
||||
isCommentAvailable: Boolean = true,
|
||||
isAdult: Boolean = false,
|
||||
isActive: Boolean = true,
|
||||
imagePath: String? = null
|
||||
): CreatorCommunity {
|
||||
return CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = isCommentAvailable,
|
||||
isAdult = isAdult,
|
||||
imagePath = imagePath,
|
||||
isActive = isActive
|
||||
).apply {
|
||||
member = owner
|
||||
entityManager.persist(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload(postId: Long): CreatorCommunity {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
return entityManager.find(CreatorCommunity::class.java, postId)
|
||||
}
|
||||
|
||||
private fun countPosts(): Long = entityManager.createQuery(
|
||||
"select count(p) from CreatorCommunity p",
|
||||
java.lang.Long::class.java
|
||||
).singleResult.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,242 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.UpdateCommunityPostFixedRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
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.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
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.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class LegacyCommunityPostCharacterizationTest {
|
||||
private lateinit var repository: CreatorCommunityRepository
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
private lateinit var service: CreatorCommunityService
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
repository = Mockito.mock(CreatorCommunityRepository::class.java)
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
service = CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
repository = repository,
|
||||
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java),
|
||||
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
|
||||
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java),
|
||||
useCanRepository = Mockito.mock(UseCanRepository::class.java),
|
||||
s3Uploader = s3Uploader,
|
||||
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
imageBucket = "image-bucket",
|
||||
contentBucket = "content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 커뮤니티 생성은 유료 또는 오디오 게시글에 이미지를 요구한다")
|
||||
fun shouldRequireImageForPaidOrAudioCommunityPost() {
|
||||
val creator = createMember(1L, "community-validation-owner")
|
||||
|
||||
val paidException = assertThrows(SodaException::class.java) {
|
||||
service.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = null,
|
||||
requestString = createRequest(price = 10),
|
||||
member = creator
|
||||
)
|
||||
}
|
||||
val audioException = assertThrows(SodaException::class.java) {
|
||||
service.createCommunityPost(
|
||||
audioFile = audioFile(),
|
||||
postImage = null,
|
||||
requestString = createRequest(price = 0),
|
||||
member = creator
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("creator.community.paid_post_image_required", paidException.messageKey)
|
||||
assertEquals("creator.community.audio_post_image_required", audioException.messageKey)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(CreatorCommunity::class.java))
|
||||
Mockito.verifyNoInteractions(s3Uploader, applicationEventPublisher, homeFollowingNewsPublishService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 커뮤니티 생성은 이미지와 오디오를 업로드하고 알림과 무료 최근 소식을 발행한다")
|
||||
fun shouldUploadMediaAndPublishNotificationAndFreeRecentNews() {
|
||||
val creator = createMember(2L, "community-create-owner")
|
||||
creator.profileImage = "profile/community-create-owner.png"
|
||||
val createdAt = LocalDateTime.of(2026, 7, 28, 9, 0)
|
||||
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<CreatorCommunity>(0).also { post ->
|
||||
post.id = 1001L
|
||||
post.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
Mockito.`when`(
|
||||
s3Uploader.upload(anyInputStream(), anyStringValue(), anyStringValue(), anyObjectMetadata())
|
||||
).thenReturn("creator_community/1001/image.png", "creator_community/1001/audio.m4a")
|
||||
|
||||
service.createCommunityPost(
|
||||
audioFile = audioFile(),
|
||||
postImage = imageFile(),
|
||||
requestString = createRequest(content = "free community post", price = 0, isAdult = true),
|
||||
member = creator
|
||||
)
|
||||
|
||||
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||
Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture())
|
||||
val event = eventCaptor.value
|
||||
assertEquals(FcmEventType.CHANGE_NOTICE, event.type)
|
||||
assertEquals(PushNotificationCategory.COMMUNITY, event.category)
|
||||
assertEquals("creator.community.fcm.new_post", event.messageKey)
|
||||
assertEquals(creator.id, event.creatorId)
|
||||
assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue)
|
||||
assertEquals(creator.id, event.deepLinkId)
|
||||
assertEquals(1001L, event.deepLinkCommentPostId)
|
||||
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
|
||||
postId = 1001L,
|
||||
creatorId = creator.id!!,
|
||||
creatorNickname = creator.nickname,
|
||||
creatorProfileImagePath = creator.profileImage,
|
||||
title = "free community post",
|
||||
body = "free community post",
|
||||
thumbnailImagePath = "creator_community/1001/image.png",
|
||||
occurredAtUtc = createdAt,
|
||||
isAdult = true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 커뮤니티 생성은 유료 게시글의 최근 소식을 발행하지 않는다")
|
||||
fun shouldSkipRecentNewsForPaidCommunityPost() {
|
||||
val creator = createMember(3L, "community-paid-owner")
|
||||
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<CreatorCommunity>(0).also { post -> post.id = 1002L }
|
||||
}
|
||||
Mockito.`when`(
|
||||
s3Uploader.upload(anyInputStream(), anyStringValue(), anyStringValue(), anyObjectMetadata())
|
||||
).thenReturn("creator_community/1002/image.png")
|
||||
|
||||
service.createCommunityPost(
|
||||
audioFile = null,
|
||||
postImage = imageFile(),
|
||||
requestString = createRequest(content = "paid community post", price = 10),
|
||||
member = creator
|
||||
)
|
||||
|
||||
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(FcmEvent::class.java))
|
||||
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 커뮤니티 고정은 최대 3개를 유지하고 soft delete는 고정 상태를 해제한다")
|
||||
fun shouldKeepFixedLimitAndClearFixedStateOnSoftDelete() {
|
||||
val creator = createMember(4L, "community-fixed-owner")
|
||||
val post = CreatorCommunity("fixed post", 0, true, false)
|
||||
post.id = 1003L
|
||||
post.member = creator
|
||||
post.isFixed = true
|
||||
post.fixedAt = LocalDateTime.of(2026, 7, 28, 10, 0)
|
||||
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
|
||||
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
|
||||
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = true), creator)
|
||||
service.modifyCommunityPost(
|
||||
postImage = null,
|
||||
requestString = """{"creatorCommunityId":${post.id},"isActive":false}""",
|
||||
member = creator
|
||||
)
|
||||
|
||||
assertFalse(post.isActive)
|
||||
assertFalse(post.isFixed)
|
||||
assertNull(post.fixedAt)
|
||||
|
||||
val otherPost = CreatorCommunity("other fixed post", 0, true, false)
|
||||
otherPost.id = 1004L
|
||||
otherPost.member = creator
|
||||
Mockito.`when`(repository.findByIdAndMemberId(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(otherPost.id!!, isFixed = true), creator)
|
||||
}
|
||||
assertEquals("creator.community.max_fixed_post_count", exception.messageKey)
|
||||
}
|
||||
|
||||
private fun createRequest(
|
||||
content: String = "community post",
|
||||
price: Int,
|
||||
isAdult: Boolean = false
|
||||
): String {
|
||||
return """{"content":"$content","price":$price,"isCommentAvailable":true,"isAdult":$isAdult}"""
|
||||
}
|
||||
|
||||
private fun imageFile(): MultipartFile {
|
||||
val pngBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||
return Mockito.mock(MultipartFile::class.java).also { file ->
|
||||
Mockito.`when`(file.bytes).thenReturn(pngBytes)
|
||||
Mockito.`when`(file.size).thenReturn(pngBytes.size.toLong())
|
||||
Mockito.`when`(file.inputStream).thenReturn(pngBytes.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioFile(): MultipartFile {
|
||||
val bytes = byteArrayOf(1, 2, 3)
|
||||
return Mockito.mock(MultipartFile::class.java).also { file ->
|
||||
Mockito.`when`(file.size).thenReturn(bytes.size.toLong())
|
||||
Mockito.`when`(file.inputStream).thenReturn(bytes.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMember(id: Long, nickname: String): Member {
|
||||
return Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = MemberRole.CREATOR
|
||||
).also { it.id = id }
|
||||
}
|
||||
|
||||
private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
|
||||
|
||||
private fun anyObjectMetadata(): ObjectMetadata = Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata()
|
||||
|
||||
private fun anyStringValue(): String = Mockito.anyString() ?: ""
|
||||
}
|
||||
Reference in New Issue
Block a user