diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt new file mode 100644 index 00000000..6c8cc3db --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt @@ -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 { + 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 { + 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 { + requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, postId, postImage, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts( + multipartRequest: MultipartHttpServletRequest, + allowedParts: Set + ) { + 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 { + 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 { + 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 { + 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 { + 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 { + return ApiResponse.ok(facade.commentReplies(characterId, postId, commentId, page, size)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt new file mode 100644 index 00000000..cbc8681a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt @@ -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 +) + +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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt new file mode 100644 index 00000000..08366137 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt @@ -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 readRequest(requestString: String, requestClass: Class): 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" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt new file mode 100644 index 00000000..283f6d5a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt @@ -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 { + 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() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt new file mode 100644 index 00000000..8c612174 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt @@ -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 { + 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")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt new file mode 100644 index 00000000..77d1574c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt @@ -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 { + mockMvc.perform(fixedRequest(fixture.characterId, fixture.firstPostId)) + .andReturn() + .response + .status + } + assertTrue(firstCountRead.await(5, TimeUnit.SECONDS)) + + val second = executor.submit { + 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 + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt new file mode 100644 index 00000000..101d1795 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt @@ -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, + 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")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt new file mode 100644 index 00000000..cb200f9f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt @@ -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,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @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")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt new file mode 100644 index 00000000..81c80599 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt @@ -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")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt new file mode 100644 index 00000000..571e02a2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt @@ -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,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @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, + 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")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt new file mode 100644 index 00000000..7eaaad48 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt @@ -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(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(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() ?: "" +}