From 7f04899ca5197b0febaf63c7d081e2789337122e Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:57:35 +0900 Subject: [PATCH] =?UTF-8?q?test(community):=20=EA=B2=8C=EC=8B=9C=EB=AC=BC?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20API=20E2E=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityEndToEndTest.kt | 345 +++++++++++++++++- 1 file changed, 343 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt index bf077514..a9ecfc29 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt @@ -5,9 +5,12 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.content.ContentType 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.member.block.BlockMember import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import org.junit.jupiter.api.DisplayName @@ -94,6 +97,145 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( Mockito.verifyNoMoreInteractions(audioContentCloudFront) } + @Test + @DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다") + fun shouldReturnCommunityPostDetailWithInitialComments() { + val fixture = createDetailFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.postId").value(fixture.postId)) + .andExpect(jsonPath("$.data.isLiked").value(true)) + .andExpect(jsonPath("$.data.comments.comments.length()").value(20)) + .andExpect(jsonPath("$.data.comments.hasNext").value(true)) + .andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(fixture.latestReplyId)) + .andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z"))) + } + + @Test + @DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다") + fun shouldReturnPagedCommunityCommentsWithLatestReply() { + val fixture = createDetailFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(21)) + .andExpect(jsonPath("$.data.comments.length()").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId)) + } + + @Test + @DisplayName("커뮤니티 답글 API는 latestReply 없이 답글 페이지를 반환한다") + fun shouldReturnPagedCommunityRepliesWithoutLatestReply() { + val fixture = createDetailFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/${fixture.firstCommentId}/replies") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.replyCount").value(1)) + .andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId)) + .andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist()) + } + + @Test + @DisplayName("댓글 불가 게시물은 빈 댓글 응답을 반환한다") + fun shouldReturnEmptyCommentsWhenCommentUnavailable() { + val fixture = createCommentUnavailableFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(0)) + .andExpect(jsonPath("$.data.comments.length()").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("성인 콘텐츠를 볼 수 없는 회원은 19금 커뮤니티 상세를 조회할 수 없다") + fun shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent() { + val fixture = createAdultBlockedFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("차단 관계가 있으면 커뮤니티 상세를 조회할 수 없다") + fun shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked() { + val fixture = createBlockedFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("커뮤니티 댓글 API는 비밀 댓글과 차단 작성자를 필터링한다") + fun shouldFilterSecretAndBlockedComments() { + val fixture = createAccessPolicyFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(2)) + .andExpect(jsonPath("$.data.comments.length()").value(2)) + .andExpect(jsonPath("$.data.comments[0].commentId").value(fixture.ownSecretCommentId)) + .andExpect(jsonPath("$.data.comments[1].commentId").value(fixture.publicCommentId)) + .andExpect(jsonPath("$.data.comments[1].latestReply.commentId").value(fixture.publicReplyId)) + } + + @Test + @DisplayName("보이지 않는 부모 댓글의 답글 조회는 오류를 반환한다") + fun shouldRejectRepliesWhenParentCommentIsNotVisible() { + val fixture = createAccessPolicyFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/${fixture.hiddenSecretCommentId}/replies") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("댓글 불가 게시물의 답글 직접 조회는 빈 응답을 반환한다") + fun shouldReturnEmptyRepliesWhenCommentUnavailable() { + val fixture = createCommentUnavailableFixtureWithReply() + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/${fixture.commentId}/replies") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.replyCount").value(0)) + .andExpect(jsonPath("$.data.replies.length()").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + private fun createFixture(): Fixture { return transactionTemplate.execute { val now = LocalDateTime.of(2026, 6, 21, 12, 0) @@ -192,12 +334,13 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( content: String, imagePath: String? = null, audioPath: String? = null, - isAdult: Boolean = false + isAdult: Boolean = false, + isCommentAvailable: Boolean = true ): CreatorCommunity { val community = CreatorCommunity( content = content, price = price, - isCommentAvailable = true, + isCommentAvailable = isCommentAvailable, isAdult = isAdult, audioPath = audioPath, imagePath = imagePath, @@ -210,6 +353,37 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( return community } + private fun saveCommunityLike(member: Member, community: CreatorCommunity): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = true) + like.member = member + like.creatorCommunity = community + entityManager.persist(like) + return like + } + + private fun saveCommunityComment( + member: Member, + community: CreatorCommunity, + parent: CreatorCommunityComment? = null, + isSecret: Boolean = false, + isActive: Boolean = true + ): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isSecret = isSecret, isActive = isActive) + comment.member = member + comment.creatorCommunity = community + comment.parent = parent + entityManager.persist(comment) + return comment + } + + private fun saveBlock(member: Member, blockedMember: Member): BlockMember { + val block = BlockMember(isActive = true) + block.member = member + block.blockedMember = blockedMember + entityManager.persist(block) + return block + } + private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan { val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false) useCan.member = member @@ -225,6 +399,147 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( .executeUpdate() } + private fun updateCommentCreatedAt(id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update CreatorCommunityComment e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun createDetailFixture(): DetailFixture { + return transactionTemplate.execute { + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + val viewer = saveMember("community-detail-viewer", MemberRole.USER) + val creator = saveMember("community-detail-creator", MemberRole.CREATOR) + val replier = saveMember("community-detail-replier", MemberRole.USER) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "detail community") + saveCommunityLike(viewer, post) + val comments = (1..21).map { saveCommunityComment(viewer, post) } + val latestReply = saveCommunityComment(replier, post, parent = comments.last()) + entityManager.flush() + comments.forEachIndexed { index, comment -> + updateCommentCreatedAt(comment.id!!, now.plusMinutes(index.toLong())) + } + updateCommentCreatedAt(latestReply.id!!, now.plusMinutes(30)) + entityManager.flush() + entityManager.clear() + DetailFixture( + viewer = viewer, + postId = post.id!!, + firstCommentId = comments.last().id!!, + latestReplyId = latestReply.id!! + ) + }!! + } + + private fun createCommentUnavailableFixture(): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-unavailable-viewer", MemberRole.USER) + val creator = saveMember("community-unavailable-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 0, + content = "comment unavailable", + isCommentAvailable = false + ) + saveCommunityComment(viewer, post) + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createAdultBlockedFixture(): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-adult-blocked-viewer", MemberRole.USER) + val creator = saveMember("community-adult-blocked-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = false) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 0, + content = "adult blocked", + isAdult = true + ) + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createBlockedFixture(): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-blocked-viewer", MemberRole.USER) + val creator = saveMember("community-blocked-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + saveBlock(viewer, creator) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "blocked") + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createAccessPolicyFixture(): AccessPolicyFixture { + return transactionTemplate.execute { + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + val viewer = saveMember("community-policy-viewer", MemberRole.USER) + val creator = saveMember("community-policy-creator", MemberRole.CREATOR) + val publicWriter = saveMember("community-policy-public-writer", MemberRole.USER) + val secretWriter = saveMember("community-policy-secret-writer", MemberRole.USER) + val blockedWriter = saveMember("community-policy-blocked-writer", MemberRole.USER) + val replyWriter = saveMember("community-policy-reply-writer", MemberRole.USER) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "policy") + val publicComment = saveCommunityComment(publicWriter, post) + val ownSecretComment = saveCommunityComment(viewer, post, isSecret = true) + val hiddenSecretComment = saveCommunityComment(secretWriter, post, isSecret = true) + saveCommunityComment(blockedWriter, post) + val publicReply = saveCommunityComment(replyWriter, post, parent = publicComment) + val hiddenSecretReply = saveCommunityComment(secretWriter, post, parent = publicComment, isSecret = true) + saveBlock(viewer, blockedWriter) + entityManager.flush() + updateCommentCreatedAt(publicComment.id!!, now.minusMinutes(3)) + updateCommentCreatedAt(ownSecretComment.id!!, now.minusMinutes(1)) + updateCommentCreatedAt(hiddenSecretComment.id!!, now) + updateCommentCreatedAt(publicReply.id!!, now.plusMinutes(1)) + updateCommentCreatedAt(hiddenSecretReply.id!!, now.plusMinutes(2)) + entityManager.flush() + entityManager.clear() + AccessPolicyFixture( + viewer = viewer, + postId = post.id!!, + publicCommentId = publicComment.id!!, + ownSecretCommentId = ownSecretComment.id!!, + hiddenSecretCommentId = hiddenSecretComment.id!!, + publicReplyId = publicReply.id!! + ) + }!! + } + + private fun createCommentUnavailableFixtureWithReply(): ReplyFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-unavailable-reply-viewer", MemberRole.USER) + val creator = saveMember("community-unavailable-reply-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 0, + content = "comment unavailable reply", + isCommentAvailable = false + ) + val comment = saveCommunityComment(viewer, post) + saveCommunityComment(viewer, post, parent = comment) + entityManager.flush() + entityManager.clear() + ReplyFixture(viewer = viewer, commentId = comment.id!!) + }!! + } + private data class Fixture( val viewer: Member, val creatorId: Long, @@ -234,4 +549,30 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( val noImagePostId: Long, val adultPurchasedPostId: Long ) + + private data class DetailFixture( + val viewer: Member, + val postId: Long, + val firstCommentId: Long, + val latestReplyId: Long + ) + + private data class PostFixture( + val viewer: Member, + val postId: Long + ) + + private data class AccessPolicyFixture( + val viewer: Member, + val postId: Long, + val publicCommentId: Long, + val ownSecretCommentId: Long, + val hiddenSecretCommentId: Long, + val publicReplyId: Long + ) + + private data class ReplyFixture( + val viewer: Member, + val commentId: Long + ) }