From f1072a4ce415ce92dc5069c90f45f23ae28e5939 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:57:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(community):=20=EA=B2=8C=EC=8B=9C=EB=AC=BC?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20API=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/CreatorChannelCommunityController.kt | 47 ++++ .../CreatorChannelCommunityFacade.kt | 53 +++++ .../CreatorChannelCommunityControllerTest.kt | 210 ++++++++++++++++++ .../CreatorChannelCommunityFacadeTest.kt | 97 ++++++++ 4 files changed, 407 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt index 1f1320f7..3901833c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt @@ -33,6 +33,53 @@ class CreatorChannelCommunityController( ) } + @GetMapping("/community-posts/{postId}") + fun getCommunityPostDetail( + @PathVariable postId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelCommunityFacade.getCommunityPostDetail( + postId = postId, + viewer = requireMember(member) + ) + ) + } + + @GetMapping("/community-posts/{postId}/comments") + fun getCommunityComments( + @PathVariable postId: Long, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelCommunityFacade.getCommunityComments( + postId = postId, + viewer = requireMember(member), + page = page, + size = size + ) + ) + } + + @GetMapping("/community-comments/{commentId}/replies") + fun getCommunityReplies( + @PathVariable commentId: Long, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelCommunityFacade.getCommunityReplies( + commentId = commentId, + viewer = requireMember(member), + page = page, + size = size + ) + ) + } + private fun requireMember(member: Member?): Member { return member ?: throw SodaException(messageKey = "common.error.bad_credentials") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt index aea1ac8f..f88a3de6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService import org.springframework.stereotype.Service @@ -29,4 +32,54 @@ class CreatorChannelCommunityFacade( ) ) } + + fun getCommunityPostDetail( + postId: Long, + viewer: Member, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityPostDetailResponse { + return CreatorChannelCommunityPostDetailResponse.from( + creatorChannelCommunityQueryService.getCommunityPostDetail( + postId = postId, + viewer = viewer, + now = now + ) + ) + } + + fun getCommunityComments( + postId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse.from( + creatorChannelCommunityQueryService.getCommunityComments( + postId = postId, + viewer = viewer, + page = page, + size = size, + now = now + ) + ) + } + + fun getCommunityReplies( + commentId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse.from( + creatorChannelCommunityQueryService.getCommunityReplies( + commentId = commentId, + viewer = viewer, + page = page, + size = size, + now = now + ) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index a84b9746..0fa5047e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -7,7 +7,12 @@ 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.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityReplyResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -147,6 +152,147 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( ) } + @Test + @DisplayName("커뮤니티 상세 조회는 비회원 요청을 거부한다") + fun shouldRejectAnonymousCommunityPostDetailRequest() { + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("커뮤니티 상세 조회는 인증 회원에게 상세와 초기 댓글을 반환한다") + fun shouldReturnCommunityPostDetailForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createDetailResponse()).`when`(facade).getCommunityPostDetail( + eqValue(101L), + eqValue(viewer), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.postId").value(101)) + .andExpect(jsonPath("$.data.isLiked").value(true)) + .andExpect(jsonPath("$.data.comments.commentCount").value(1)) + .andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(202)) + } + + @Test + @DisplayName("커뮤니티 댓글 조회는 인증 회원에게 댓글 페이지를 반환한다") + fun shouldReturnCommunityCommentsForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createCommentsResponse()).`when`(facade).getCommunityComments( + eqValue(101L), + eqValue(viewer), + eqValue(0), + eqValue(20), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101/comments") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(1)) + .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(202)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("커뮤니티 댓글 조회는 raw page와 size를 facade에 전달한다") + fun shouldPassRawCommentPageAndSizeToFacade() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createCommentsResponse(page = 0, size = 50)).`when`(facade).getCommunityComments( + eqValue(101L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101/comments") + .param("page", "-1") + .param("size", "100") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(50)) + + Mockito.verify(facade).getCommunityComments( + eqValue(101L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + } + + @Test + @DisplayName("커뮤니티 답글 조회는 인증 회원에게 답글 페이지를 반환한다") + fun shouldReturnCommunityRepliesForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createRepliesResponse()).`when`(facade).getCommunityReplies( + eqValue(201L), + eqValue(viewer), + eqValue(0), + eqValue(20), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/201/replies") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.replyCount").value(1)) + .andExpect(jsonPath("$.data.replies[0].commentId").value(202)) + .andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist()) + } + + @Test + @DisplayName("커뮤니티 답글 조회는 raw page와 size를 facade에 전달한다") + fun shouldPassRawReplyPageAndSizeToFacade() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createRepliesResponse(page = 0, size = 50)).`when`(facade).getCommunityReplies( + eqValue(201L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/201/replies") + .param("page", "-1") + .param("size", "100") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(50)) + + Mockito.verify(facade).getCommunityReplies( + eqValue(201L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + } + private fun eqValue(value: T): T { return Mockito.eq(value) ?: value } @@ -194,4 +340,68 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( hasNext = false ) } + + private fun createDetailResponse(): CreatorChannelCommunityPostDetailResponse { + return CreatorChannelCommunityPostDetailResponse( + postId = 101L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + createdAtUtc = "2026-06-21T03:30:00Z", + content = "content", + imageUrl = null, + audioUrl = null, + price = 100, + isCommentAvailable = true, + existOrdered = true, + likeCount = 7, + commentCount = 1, + isPinned = true, + isLiked = true, + comments = createCommentsResponse() + ) + } + + private fun createCommentsResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse( + commentCount = 1, + comments = listOf( + CreatorChannelCommunityCommentResponse( + commentId = 201L, + writerProfileImageUrl = "https://cdn.test/writer.png", + writerNickname = "writer", + content = "comment", + createdAtUtc = "2026-07-06T03:00:00Z", + latestReply = CreatorChannelCommunityReplyResponse( + commentId = 202L, + writerProfileImageUrl = "https://cdn.test/replier.png", + writerNickname = "replier", + content = "reply", + createdAtUtc = "2026-07-06T03:01:00Z" + ) + ) + ), + page = page, + size = size, + hasNext = false + ) + } + + private fun createRepliesResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse( + replyCount = 1, + replies = listOf( + CreatorChannelCommunityReplyResponse( + commentId = 202L, + writerProfileImageUrl = "https://cdn.test/replier.png", + writerNickname = "replier", + content = "reply", + createdAtUtc = "2026-07-06T03:01:00Z" + ) + ), + page = page, + size = size, + hasNext = false + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt index 1ce40cd4..26f0c976 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt @@ -6,7 +6,12 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage import org.junit.jupiter.api.Assertions.assertEquals @@ -87,6 +92,56 @@ class CreatorChannelCommunityFacadeTest { assertTrue(response.hasNext) } + @Test + @DisplayName("커뮤니티 상세 facade는 service 결과를 상세 응답 DTO로 변환한다") + fun shouldReturnCommunityPostDetailResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + Mockito.doReturn(createDetail()).`when`(service).getCommunityPostDetail(101L, viewer, now) + + val response = facade.getCommunityPostDetail(101L, viewer, now) + + assertEquals(101L, response.postId) + assertTrue(response.isLiked) + assertEquals(1, response.comments.commentCount) + assertEquals(201L, response.comments.comments.first().latestReply?.commentId) + } + + @Test + @DisplayName("커뮤니티 댓글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다") + fun shouldReturnCommunityCommentsResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + Mockito.doReturn(createComments()).`when`(service).getCommunityComments(101L, viewer, -1, 100, now) + + val response = facade.getCommunityComments(101L, viewer, -1, 100, now) + + assertEquals(1, response.commentCount) + assertEquals(0, response.page) + assertEquals(20, response.size) + assertEquals(201L, response.comments.first().latestReply?.commentId) + } + + @Test + @DisplayName("커뮤니티 답글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다") + fun shouldReturnCommunityRepliesResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + Mockito.doReturn(createReplies()).`when`(service).getCommunityReplies(201L, viewer, -1, 100, now) + + val response = facade.getCommunityReplies(201L, viewer, -1, 100, now) + + assertEquals(1, response.replyCount) + assertEquals(201L, response.replies.first().commentId) + assertTrue(response.hasNext) + } + private fun createMember(id: Long): Member { return Member( email = "viewer$id@test.com", @@ -139,4 +194,46 @@ class CreatorChannelCommunityFacadeTest { hasNext = true ) } + + private fun createDetail(): CreatorChannelCommunityPostDetail { + return CreatorChannelCommunityPostDetail( + post = createTab().communityPosts.first(), + comments = createComments() + ) + } + + private fun createComments(): CreatorChannelCommunityComments { + return CreatorChannelCommunityComments( + commentCount = 1, + comments = listOf( + CreatorChannelCommunityComment( + commentId = 200L, + writerProfileImageUrl = "https://cdn.test/writer.png", + writerNickname = "writer", + content = "comment", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), + latestReply = createReplies().replies.first() + ) + ), + page = CreatorChannelPage(page = 0, size = 20), + hasNext = false + ) + } + + private fun createReplies(): CreatorChannelCommunityReplies { + return CreatorChannelCommunityReplies( + replyCount = 1, + replies = listOf( + CreatorChannelCommunityReply( + commentId = 201L, + writerProfileImageUrl = "https://cdn.test/replier.png", + writerNickname = "replier", + content = "reply", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 1) + ) + ), + page = CreatorChannelPage(page = 0, size = 20), + hasNext = true + ) + } }