feat(community): 게시물 상세 API 엔드포인트를 추가한다
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user