test(community): 게시물 상세 API E2E를 추가한다
This commit is contained in:
@@ -5,9 +5,12 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCan
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
@@ -94,6 +97,145 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
|
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다")
|
||||||
|
fun shouldReturnCommunityPostDetailWithInitialComments() {
|
||||||
|
val fixture = createDetailFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.postId").value(fixture.postId))
|
||||||
|
.andExpect(jsonPath("$.data.isLiked").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments.length()").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.comments.hasNext").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(fixture.latestReplyId))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다")
|
||||||
|
fun shouldReturnPagedCommunityCommentsWithLatestReply() {
|
||||||
|
val fixture = createDetailFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(21))
|
||||||
|
.andExpect(jsonPath("$.data.comments.length()").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 답글 API는 latestReply 없이 답글 페이지를 반환한다")
|
||||||
|
fun shouldReturnPagedCommunityRepliesWithoutLatestReply() {
|
||||||
|
val fixture = createDetailFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/${fixture.firstCommentId}/replies")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.replyCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId))
|
||||||
|
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 불가 게시물은 빈 댓글 응답을 반환한다")
|
||||||
|
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
|
||||||
|
val fixture = createCommentUnavailableFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.comments.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("성인 콘텐츠를 볼 수 없는 회원은 19금 커뮤니티 상세를 조회할 수 없다")
|
||||||
|
fun shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent() {
|
||||||
|
val fixture = createAdultBlockedFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 관계가 있으면 커뮤니티 상세를 조회할 수 없다")
|
||||||
|
fun shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked() {
|
||||||
|
val fixture = createBlockedFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 API는 비밀 댓글과 차단 작성자를 필터링한다")
|
||||||
|
fun shouldFilterSecretAndBlockedComments() {
|
||||||
|
val fixture = createAccessPolicyFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.comments.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.comments[0].commentId").value(fixture.ownSecretCommentId))
|
||||||
|
.andExpect(jsonPath("$.data.comments[1].commentId").value(fixture.publicCommentId))
|
||||||
|
.andExpect(jsonPath("$.data.comments[1].latestReply.commentId").value(fixture.publicReplyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("보이지 않는 부모 댓글의 답글 조회는 오류를 반환한다")
|
||||||
|
fun shouldRejectRepliesWhenParentCommentIsNotVisible() {
|
||||||
|
val fixture = createAccessPolicyFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/${fixture.hiddenSecretCommentId}/replies")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 불가 게시물의 답글 직접 조회는 빈 응답을 반환한다")
|
||||||
|
fun shouldReturnEmptyRepliesWhenCommentUnavailable() {
|
||||||
|
val fixture = createCommentUnavailableFixtureWithReply()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/${fixture.commentId}/replies")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.replyCount").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.replies.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createFixture(): Fixture {
|
private fun createFixture(): Fixture {
|
||||||
return transactionTemplate.execute {
|
return transactionTemplate.execute {
|
||||||
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
||||||
@@ -192,12 +334,13 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
content: String,
|
content: String,
|
||||||
imagePath: String? = null,
|
imagePath: String? = null,
|
||||||
audioPath: String? = null,
|
audioPath: String? = null,
|
||||||
isAdult: Boolean = false
|
isAdult: Boolean = false,
|
||||||
|
isCommentAvailable: Boolean = true
|
||||||
): CreatorCommunity {
|
): CreatorCommunity {
|
||||||
val community = CreatorCommunity(
|
val community = CreatorCommunity(
|
||||||
content = content,
|
content = content,
|
||||||
price = price,
|
price = price,
|
||||||
isCommentAvailable = true,
|
isCommentAvailable = isCommentAvailable,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
audioPath = audioPath,
|
audioPath = audioPath,
|
||||||
imagePath = imagePath,
|
imagePath = imagePath,
|
||||||
@@ -210,6 +353,37 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
return community
|
return community
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityLike(member: Member, community: CreatorCommunity): CreatorCommunityLike {
|
||||||
|
val like = CreatorCommunityLike(isActive = true)
|
||||||
|
like.member = member
|
||||||
|
like.creatorCommunity = community
|
||||||
|
entityManager.persist(like)
|
||||||
|
return like
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityComment(
|
||||||
|
member: Member,
|
||||||
|
community: CreatorCommunity,
|
||||||
|
parent: CreatorCommunityComment? = null,
|
||||||
|
isSecret: Boolean = false,
|
||||||
|
isActive: Boolean = true
|
||||||
|
): CreatorCommunityComment {
|
||||||
|
val comment = CreatorCommunityComment(comment = "comment", isSecret = isSecret, isActive = isActive)
|
||||||
|
comment.member = member
|
||||||
|
comment.creatorCommunity = community
|
||||||
|
comment.parent = parent
|
||||||
|
entityManager.persist(comment)
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
|
||||||
|
val block = BlockMember(isActive = true)
|
||||||
|
block.member = member
|
||||||
|
block.blockedMember = blockedMember
|
||||||
|
entityManager.persist(block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan {
|
private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan {
|
||||||
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false)
|
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false)
|
||||||
useCan.member = member
|
useCan.member = member
|
||||||
@@ -225,6 +399,147 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
.executeUpdate()
|
.executeUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateCommentCreatedAt(id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update CreatorCommunityComment e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDetailFixture(): DetailFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
val viewer = saveMember("community-detail-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
|
||||||
|
val replier = saveMember("community-detail-replier", MemberRole.USER)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, content = "detail community")
|
||||||
|
saveCommunityLike(viewer, post)
|
||||||
|
val comments = (1..21).map { saveCommunityComment(viewer, post) }
|
||||||
|
val latestReply = saveCommunityComment(replier, post, parent = comments.last())
|
||||||
|
entityManager.flush()
|
||||||
|
comments.forEachIndexed { index, comment ->
|
||||||
|
updateCommentCreatedAt(comment.id!!, now.plusMinutes(index.toLong()))
|
||||||
|
}
|
||||||
|
updateCommentCreatedAt(latestReply.id!!, now.plusMinutes(30))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
DetailFixture(
|
||||||
|
viewer = viewer,
|
||||||
|
postId = post.id!!,
|
||||||
|
firstCommentId = comments.last().id!!,
|
||||||
|
latestReplyId = latestReply.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCommentUnavailableFixture(): PostFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-unavailable-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-unavailable-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(
|
||||||
|
creator = creator,
|
||||||
|
isFixed = false,
|
||||||
|
price = 0,
|
||||||
|
content = "comment unavailable",
|
||||||
|
isCommentAvailable = false
|
||||||
|
)
|
||||||
|
saveCommunityComment(viewer, post)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
PostFixture(viewer = viewer, postId = post.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAdultBlockedFixture(): PostFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-adult-blocked-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-adult-blocked-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = false)
|
||||||
|
val post = saveCommunity(
|
||||||
|
creator = creator,
|
||||||
|
isFixed = false,
|
||||||
|
price = 0,
|
||||||
|
content = "adult blocked",
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
PostFixture(viewer = viewer, postId = post.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBlockedFixture(): PostFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-blocked-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-blocked-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
saveBlock(viewer, creator)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, content = "blocked")
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
PostFixture(viewer = viewer, postId = post.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAccessPolicyFixture(): AccessPolicyFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
val viewer = saveMember("community-policy-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-policy-creator", MemberRole.CREATOR)
|
||||||
|
val publicWriter = saveMember("community-policy-public-writer", MemberRole.USER)
|
||||||
|
val secretWriter = saveMember("community-policy-secret-writer", MemberRole.USER)
|
||||||
|
val blockedWriter = saveMember("community-policy-blocked-writer", MemberRole.USER)
|
||||||
|
val replyWriter = saveMember("community-policy-reply-writer", MemberRole.USER)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, content = "policy")
|
||||||
|
val publicComment = saveCommunityComment(publicWriter, post)
|
||||||
|
val ownSecretComment = saveCommunityComment(viewer, post, isSecret = true)
|
||||||
|
val hiddenSecretComment = saveCommunityComment(secretWriter, post, isSecret = true)
|
||||||
|
saveCommunityComment(blockedWriter, post)
|
||||||
|
val publicReply = saveCommunityComment(replyWriter, post, parent = publicComment)
|
||||||
|
val hiddenSecretReply = saveCommunityComment(secretWriter, post, parent = publicComment, isSecret = true)
|
||||||
|
saveBlock(viewer, blockedWriter)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCommentCreatedAt(publicComment.id!!, now.minusMinutes(3))
|
||||||
|
updateCommentCreatedAt(ownSecretComment.id!!, now.minusMinutes(1))
|
||||||
|
updateCommentCreatedAt(hiddenSecretComment.id!!, now)
|
||||||
|
updateCommentCreatedAt(publicReply.id!!, now.plusMinutes(1))
|
||||||
|
updateCommentCreatedAt(hiddenSecretReply.id!!, now.plusMinutes(2))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
AccessPolicyFixture(
|
||||||
|
viewer = viewer,
|
||||||
|
postId = post.id!!,
|
||||||
|
publicCommentId = publicComment.id!!,
|
||||||
|
ownSecretCommentId = ownSecretComment.id!!,
|
||||||
|
hiddenSecretCommentId = hiddenSecretComment.id!!,
|
||||||
|
publicReplyId = publicReply.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCommentUnavailableFixtureWithReply(): ReplyFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-unavailable-reply-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-unavailable-reply-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(
|
||||||
|
creator = creator,
|
||||||
|
isFixed = false,
|
||||||
|
price = 0,
|
||||||
|
content = "comment unavailable reply",
|
||||||
|
isCommentAvailable = false
|
||||||
|
)
|
||||||
|
val comment = saveCommunityComment(viewer, post)
|
||||||
|
saveCommunityComment(viewer, post, parent = comment)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
ReplyFixture(viewer = viewer, commentId = comment.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
private data class Fixture(
|
private data class Fixture(
|
||||||
val viewer: Member,
|
val viewer: Member,
|
||||||
val creatorId: Long,
|
val creatorId: Long,
|
||||||
@@ -234,4 +549,30 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
val noImagePostId: Long,
|
val noImagePostId: Long,
|
||||||
val adultPurchasedPostId: Long
|
val adultPurchasedPostId: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class DetailFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val postId: Long,
|
||||||
|
val firstCommentId: Long,
|
||||||
|
val latestReplyId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class PostFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val postId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AccessPolicyFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val postId: Long,
|
||||||
|
val publicCommentId: Long,
|
||||||
|
val ownSecretCommentId: Long,
|
||||||
|
val hiddenSecretCommentId: Long,
|
||||||
|
val publicReplyId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReplyFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val commentId: Long
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user