From 9e524d626ae3fe909cbb06c51a1e0ff9bfb14c41 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 9 Jul 2026 00:52:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9D=84=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 --- .../CreatorChannelCommunityDetailResponse.kt | 63 ++++++++ .../creator/channel/data/CreatorChannelApi.kt | 25 ++++ .../channel/data/CreatorChannelRepository.kt | 29 ++++ ...eatorChannelCommunityDetailContractTest.kt | 135 ++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/data/CreatorChannelCommunityDetailResponse.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailContractTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/data/CreatorChannelCommunityDetailResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/data/CreatorChannelCommunityDetailResponse.kt new file mode 100644 index 00000000..9a264212 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/data/CreatorChannelCommunityDetailResponse.kt @@ -0,0 +1,63 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class CreatorChannelCommunityPostDetailResponse( + @SerializedName("postId") val postId: Long, + @SerializedName("creatorId") val creatorId: Long, + @SerializedName("creatorNickname") val creatorNickname: String, + @SerializedName("creatorProfileUrl") val creatorProfileUrl: String?, + @SerializedName("createdAtUtc") val createdAtUtc: String, + @SerializedName("content") val content: String, + @SerializedName("imageUrl") val imageUrl: String?, + @SerializedName("audioUrl") val audioUrl: String?, + @SerializedName("price") val price: Int, + @SerializedName("existOrdered") val existOrdered: Boolean, + @SerializedName("isCommentAvailable") val isCommentAvailable: Boolean, + @SerializedName("likeCount") val likeCount: Int, + @SerializedName("commentCount") val commentCount: Int, + @SerializedName("isLiked") val isLiked: Boolean, + @SerializedName("isPinned") val isPinned: Boolean, + @SerializedName("comments") val comments: CreatorChannelCommunityCommentsResponse +) + +@Keep +data class CreatorChannelCommunityCommentsResponse( + @SerializedName("commentCount") val commentCount: Int, + @SerializedName("comments") val comments: List, + @SerializedName("page") val page: Int, + @SerializedName("size") val size: Int, + @SerializedName("hasNext") val hasNext: Boolean +) + +@Keep +data class CreatorChannelCommunityCommentResponse( + @SerializedName("commentId") val commentId: Long, + @SerializedName("writerId") val writerId: Long, + @SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String, + @SerializedName("writerNickname") val writerNickname: String, + @SerializedName("content") val content: String, + @SerializedName("isSecret") val isSecret: Boolean, + @SerializedName("createdAtUtc") val createdAtUtc: String, + @SerializedName("latestReply") val latestReply: CreatorChannelCommunityReplyResponse? +) + +@Keep +data class CreatorChannelCommunityReplyResponse( + @SerializedName("commentId") val commentId: Long, + @SerializedName("writerId") val writerId: Long, + @SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String, + @SerializedName("writerNickname") val writerNickname: String, + @SerializedName("content") val content: String, + @SerializedName("createdAtUtc") val createdAtUtc: String +) + +@Keep +data class CreatorChannelCommunityRepliesResponse( + @SerializedName("replies") val replies: List, + @SerializedName("page") val page: Int, + @SerializedName("size") val size: Int, + @SerializedName("hasNext") val hasNext: Boolean +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelApi.kt index d9c6c117..a2a31305 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelApi.kt @@ -5,6 +5,9 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.v2.common.data.ContentSort import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityRepliesResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse @@ -57,6 +60,28 @@ interface CreatorChannelApi { @Header("Authorization") authHeader: String ): Single> + @GET("/api/v2/creator-channels/community-posts/{postId}") + fun getCommunityPostDetail( + @Path("postId") postId: Long, + @Header("Authorization") authHeader: String + ): Single> + + @GET("/api/v2/creator-channels/community-posts/{postId}/comments") + fun getCommunityPostComments( + @Path("postId") postId: Long, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single> + + @GET("/api/v2/creator-channels/community-comments/{commentId}/replies") + fun getCommunityCommentReplies( + @Path("commentId") commentId: Long, + @Query("page") page: Int, + @Query("size") size: Int, + @Header("Authorization") authHeader: String + ): Single> + @GET("/api/v2/creator-channels/{creatorId}/fan-talks") fun getFanTalks( @Path("creatorId") creatorId: Long, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt index bd11ed31..9e3217a6 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelRepository.kt @@ -79,6 +79,35 @@ class CreatorChannelRepository( authHeader = token ) + fun getCommunityPostDetail(postId: Long, token: String) = api.getCommunityPostDetail( + postId = postId, + authHeader = token + ) + + fun getCommunityPostComments( + postId: Long, + page: Int = 0, + size: Int = 20, + token: String + ) = api.getCommunityPostComments( + postId = postId, + page = page, + size = size, + authHeader = token + ) + + fun getCommunityCommentReplies( + commentId: Long, + page: Int = 0, + size: Int = 20, + token: String + ) = api.getCommunityCommentReplies( + commentId = commentId, + page = page, + size = size, + authHeader = token + ) + fun getFanTalks( creatorId: Long, page: Int, diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailContractTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailContractTest.kt new file mode 100644 index 00000000..3524509b --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityDetailContractTest.kt @@ -0,0 +1,135 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community + +import com.google.gson.annotations.SerializedName +import io.reactivex.rxjava3.core.Single +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityRepliesResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelApi +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +class CreatorChannelCommunityDetailContractTest { + + @Test + fun `커뮤니티 상세 API source는 신규 v2 endpoint와 ApiResponse Single 계약을 제공한다`() { + val api = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelApi.kt" + ).readText() + + assertTrue(api.contains("@GET(\"/api/v2/creator-channels/community-posts/{postId}\")")) + assertTrue(api.contains("@GET(\"/api/v2/creator-channels/community-posts/{postId}/comments\")")) + assertTrue(api.contains("@GET(\"/api/v2/creator-channels/community-comments/{commentId}/replies\")")) + assertTrue(api.contains("Single>")) + assertTrue(api.contains("Single>")) + assertTrue(api.contains("Single>")) + } + + @Test + fun `커뮤니티 상세 repository는 댓글과 답글 기본 page 0 size 20 계약을 제공한다`() { + val api = mock() + val repository = CreatorChannelRepository(api, mock(), mock(), mock(), mock()) + whenever(api.getCommunityPostDetail(11L, "Bearer token")).thenReturn(Single.never()) + whenever(api.getCommunityPostComments(11L, 0, 20, "Bearer token")).thenReturn(Single.never()) + whenever(api.getCommunityCommentReplies(22L, 0, 20, "Bearer token")).thenReturn(Single.never()) + + repository.getCommunityPostDetail(11L, "Bearer token") + repository.getCommunityPostComments(11L, token = "Bearer token") + repository.getCommunityCommentReplies(22L, token = "Bearer token") + + verify(api).getCommunityPostDetail(11L, "Bearer token") + verify(api).getCommunityPostComments(11L, 0, 20, "Bearer token") + verify(api).getCommunityCommentReplies(22L, 0, 20, "Bearer token") + } + + @Test + fun `커뮤니티 상세 응답 모델은 SerializedName과 nullable latestReply 계약을 제공한다`() { + val reply = CreatorChannelCommunityReplyResponse( + commentId = 44L, + writerId = 55L, + writerProfileImageUrl = "reply.png", + writerNickname = "reply member", + content = "reply", + createdAtUtc = "2026-07-08T00:02:00Z" + ) + val comment = CreatorChannelCommunityCommentResponse( + commentId = 22L, + writerId = 33L, + writerProfileImageUrl = "member.png", + writerNickname = "member", + content = "comment", + isSecret = false, + createdAtUtc = "2026-07-08T00:01:00Z", + latestReply = reply + ) + val comments = CreatorChannelCommunityCommentsResponse( + commentCount = 1, + comments = listOf(comment), + page = 0, + size = 20, + hasNext = false + ) + val detail = CreatorChannelCommunityPostDetailResponse( + postId = 11L, + creatorId = 100L, + creatorNickname = "creator", + creatorProfileUrl = null, + createdAtUtc = "2026-07-08T00:00:00Z", + content = "content", + imageUrl = null, + audioUrl = null, + price = 0, + existOrdered = true, + isCommentAvailable = true, + likeCount = 1, + commentCount = 2, + isLiked = false, + isPinned = false, + comments = comments + ) + + assertEquals(11L, detail.postId) + assertEquals(comments, detail.comments) + assertEquals(reply, comment.latestReply) + assertEquals(44L, reply.commentId) + assertNotNull( + CreatorChannelCommunityPostDetailResponse::class.java.getDeclaredField("postId") + .getAnnotation(SerializedName::class.java) + ) + assertNotNull( + CreatorChannelCommunityPostDetailResponse::class.java.getDeclaredField("comments") + .getAnnotation(SerializedName::class.java) + ) + assertNotNull( + CreatorChannelCommunityCommentResponse::class.java.getDeclaredField("latestReply") + .getAnnotation(SerializedName::class.java) + ) + assertNotNull( + CreatorChannelCommunityCommentResponse::class.java.getDeclaredField("writerId") + .getAnnotation(SerializedName::class.java) + ) + assertNotNull( + CreatorChannelCommunityReplyResponse::class.java.getDeclaredField("writerId") + .getAnnotation(SerializedName::class.java) + ) + assertEquals(CreatorChannelCommunityCommentsResponse(1, listOf(comment), 0, 20, false).comments.first(), comment) + assertEquals(CreatorChannelCommunityRepliesResponse(listOf(reply), 0, 20, false).replies.first(), reply) + } + + private fun projectFile(path: String): File { + var current = File(checkNotNull(System.getProperty("user.dir"))).absoluteFile + while (current.parentFile != null && !File(current, "settings.gradle").exists()) { + current = checkNotNull(current.parentFile) + } + return File(current, path) + } +}