test(community): 게시물 상세 API E2E를 추가한다

This commit is contained in:
2026-07-07 11:57:35 +09:00
parent f1072a4ce4
commit 7f04899ca5

View File

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