test #443

Merged
klaus merged 25 commits from test into main 2026-08-03 06:20:48 +00:00
11 changed files with 2985 additions and 0 deletions
Showing only changes of commit c45ad98db8 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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