feat(community): 게시물 상세 API 엔드포인트를 추가한다

This commit is contained in:
2026-07-07 11:57:01 +09:00
parent 3bdf8353f6
commit f1072a4ce4
4 changed files with 407 additions and 0 deletions

View File

@@ -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")
}

View File

@@ -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
)
)
}
}

View File

@@ -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 <T> 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
)
}
}

View File

@@ -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
)
}
}