From fe9adf35e74b2ff15a38787eec17fbd40ac8db99 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:55:33 +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=20=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...eatorChannelCommunityPostDetailResponse.kt | 139 ++++++++++++++++++ .../CreatorChannelCommunityPostDetail.kt | 40 +++++ ...rChannelCommunityPostDetailResponseTest.kt | 99 +++++++++++++ .../CreatorChannelCommunityQueryPolicyTest.kt | 14 ++ 4 files changed, 292 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt new file mode 100644 index 00000000..1e64b15c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.extensions.toUtcIso +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.CreatorChannelCommunityPostDetail +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply + +data class CreatorChannelCommunityPostDetailResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean, + @JsonProperty("isLiked") + val isLiked: Boolean, + val comments: CreatorChannelCommunityCommentsResponse +) { + companion object { + fun from(detail: CreatorChannelCommunityPostDetail): CreatorChannelCommunityPostDetailResponse { + val post = detail.post + return CreatorChannelCommunityPostDetailResponse( + postId = post.postId, + creatorId = post.creatorId, + creatorNickname = post.creatorNickname, + creatorProfileUrl = post.creatorProfileUrl, + createdAtUtc = post.createdAt.toUtcIso(), + content = post.content, + imageUrl = post.imageUrl, + audioUrl = post.audioUrl, + price = post.price, + isCommentAvailable = post.isCommentAvailable, + existOrdered = post.existOrdered, + likeCount = post.likeCount, + commentCount = post.commentCount, + isPinned = post.isPinned, + isLiked = post.isLiked, + comments = CreatorChannelCommunityCommentsResponse.from(detail.comments) + ) + } + } +} + +data class CreatorChannelCommunityCommentsResponse( + val commentCount: Int, + val comments: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(comments: CreatorChannelCommunityComments): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse( + commentCount = comments.commentCount, + comments = comments.comments.map(CreatorChannelCommunityCommentResponse::from), + page = comments.page.page, + size = comments.page.size, + hasNext = comments.hasNext + ) + } + } +} + +data class CreatorChannelCommunityCommentResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String, + val latestReply: CreatorChannelCommunityReplyResponse? +) { + companion object { + fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse { + return CreatorChannelCommunityCommentResponse( + commentId = comment.commentId, + writerProfileImageUrl = comment.writerProfileImageUrl, + writerNickname = comment.writerNickname, + content = comment.content, + createdAtUtc = comment.createdAt.toUtcIso(), + latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from) + ) + } + } +} + +data class CreatorChannelCommunityRepliesResponse( + val replyCount: Int, + val replies: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(replies: CreatorChannelCommunityReplies): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse( + replyCount = replies.replyCount, + replies = replies.replies.map(CreatorChannelCommunityReplyResponse::from), + page = replies.page.page, + size = replies.page.size, + hasNext = replies.hasNext + ) + } + } +} + +data class CreatorChannelCommunityReplyResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String +) { + companion object { + fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse { + return CreatorChannelCommunityReplyResponse( + commentId = reply.commentId, + writerProfileImageUrl = reply.writerProfileImageUrl, + writerNickname = reply.writerNickname, + content = reply.content, + createdAtUtc = reply.createdAt.toUtcIso() + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt new file mode 100644 index 00000000..febccbbb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import java.time.LocalDateTime + +data class CreatorChannelCommunityPostDetail( + val post: CreatorChannelCommunityPost, + val comments: CreatorChannelCommunityComments +) + +data class CreatorChannelCommunityComments( + val commentCount: Int, + val comments: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityComment( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime, + val latestReply: CreatorChannelCommunityReply? +) + +data class CreatorChannelCommunityReplies( + val replyCount: Int, + val replies: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityReply( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt new file mode 100644 index 00000000..493461bc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +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.live.domain.CreatorChannelPage +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 java.time.LocalDateTime + +class CreatorChannelCommunityPostDetailResponseTest { + @Test + @DisplayName("상세 응답 DTO는 is prefix boolean 필드명과 댓글/답글 구조를 유지한다") + fun shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse() { + val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build()) + val response = CreatorChannelCommunityPostDetailResponse.from(createDetail()) + val replies = CreatorChannelCommunityRepliesResponse.from(createReplies()) + + val detailJson = mapper.readTree(mapper.writeValueAsString(response)) + val repliesJson = mapper.readTree(mapper.writeValueAsString(replies)) + + assertTrue(detailJson["isCommentAvailable"].asBoolean()) + assertTrue(detailJson["isPinned"].asBoolean()) + assertTrue(detailJson["isLiked"].asBoolean()) + assertTrue(detailJson["comments"]["hasNext"].asBoolean()) + assertTrue(detailJson["comments"]["comments"][0].has("latestReply")) + assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("commentId")) + assertTrue(repliesJson["hasNext"].asBoolean()) + assertFalse(repliesJson["replies"][0].has("latestReply")) + } + + private fun createDetail(): CreatorChannelCommunityPostDetail { + val reply = CreatorChannelCommunityReply( + commentId = 2L, + writerProfileImageUrl = "https://cdn.test/reply.png", + writerNickname = "reply-writer", + content = "reply", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 1) + ) + return CreatorChannelCommunityPostDetail( + post = CreatorChannelCommunityPost( + postId = 1L, + creatorId = 10L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + imageUrl = "https://cdn.test/image.png", + audioUrl = null, + content = "content", + price = 0, + createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), + existOrdered = false, + isCommentAvailable = true, + likeCount = 1, + commentCount = 1, + isPinned = true, + isLiked = true + ), + comments = CreatorChannelCommunityComments( + commentCount = 1, + comments = listOf( + CreatorChannelCommunityComment( + commentId = 1L, + writerProfileImageUrl = "https://cdn.test/comment.png", + writerNickname = "comment-writer", + content = "comment", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), + latestReply = reply + ) + ), + page = CreatorChannelPage(0, 20), + hasNext = true + ) + ) + } + + private fun createReplies(): CreatorChannelCommunityReplies { + return CreatorChannelCommunityReplies( + replyCount = 1, + replies = listOf( + CreatorChannelCommunityReply( + commentId = 2L, + writerProfileImageUrl = "https://cdn.test/reply.png", + writerNickname = "reply-writer", + content = "reply", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 1) + ) + ), + page = CreatorChannelPage(0, 20), + hasNext = true + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt index d569ddf3..d91182d8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt @@ -41,6 +41,20 @@ class CreatorChannelCommunityQueryPolicyTest { assertEquals(51, maximumPage.fetchLimit) } + @Test + @DisplayName("커뮤니티 댓글도 커뮤니티 탭과 같은 page fallback 정책을 사용한다") + fun shouldUseSamePageFallbackForCommunityComments() { + val defaultPage = policy.createPage(page = null, size = null) + val boundedPage = policy.createPage(page = -1, size = 100) + + assertEquals(0, defaultPage.page) + assertEquals(20, defaultPage.size) + assertEquals(21, defaultPage.fetchLimit) + assertEquals(0, boundedPage.page) + assertEquals(50, boundedPage.size) + assertEquals(51, boundedPage.fetchLimit) + } + @Test @DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다") fun shouldLimitItemsAndCalculateHasNext() {