test #432

Merged
klaus merged 17 commits from test into main 2026-07-07 18:07:28 +00:00
3 changed files with 384 additions and 2 deletions
Showing only changes of commit 3bdf8353f6 - Show all commits

View File

@@ -2,15 +2,22 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.application
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
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.CreatorChannelCommunityQueryPolicy
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.community.port.out.CreatorChannelCommunityCommentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
@@ -93,6 +100,115 @@ class CreatorChannelCommunityQueryService(
.map { it.toDomain(viewerId) }
}
fun getCommunityPostDetail(
postId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityPostDetail {
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
return CreatorChannelCommunityPostDetail(
post = postRecord.toDomain(viewerId),
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
)
}
fun getCommunityComments(
postId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityComments {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
if (!postRecord.isCommentAvailable) {
return CreatorChannelCommunityComments(
commentCount = 0,
comments = emptyList(),
page = communityPage,
hasNext = false
)
}
val fetchedComments = queryPort.findCommunityComments(
postId = postId,
viewerId = viewerId,
creatorId = postRecord.creatorId,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
val limitedComments = queryPolicy.limitItems(fetchedComments, communityPage)
val latestReplies = queryPort.findLatestRepliesByCommentIds(
commentIds = limitedComments.map { it.commentId },
viewerId = viewerId
).associateBy { it.parentCommentId }
return CreatorChannelCommunityComments(
commentCount = queryPort.countCommunityComments(
postId = postId,
viewerId = viewerId,
creatorId = postRecord.creatorId
),
comments = limitedComments.map { it.toCommentDomain(latestReplies[it.commentId]?.toReplyDomain()) },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedComments, communityPage)
)
}
fun getCommunityReplies(
commentId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityReplies {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val context = queryPort.findRootCommentContext(commentId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, context.creatorId)
if (!context.isCommentAvailable) {
return CreatorChannelCommunityReplies(
replyCount = 0,
replies = emptyList(),
page = communityPage,
hasNext = false
)
}
val fetchedReplies = queryPort.findCommunityReplies(
commentId = commentId,
viewerId = viewerId,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
return CreatorChannelCommunityReplies(
replyCount = queryPort.countCommunityReplies(commentId, viewerId),
replies = queryPolicy.limitItems(fetchedReplies, communityPage).map { it.toReplyDomain() },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedReplies, communityPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
@@ -100,6 +216,12 @@ class CreatorChannelCommunityQueryService(
}
}
private fun validateNotBlocked(queryPort: CreatorChannelCommunityQueryPort, viewerId: Long, creatorId: Long) {
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
}
}
private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost {
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
return CreatorChannelCommunityPost(
@@ -126,6 +248,29 @@ class CreatorChannelCommunityQueryService(
)
}
private fun CreatorChannelCommunityCommentRecord.toCommentDomain(
latestReply: CreatorChannelCommunityReply?
): CreatorChannelCommunityComment {
return CreatorChannelCommunityComment(
commentId = commentId,
writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
content = content,
createdAt = createdAt,
latestReply = latestReply
)
}
private fun CreatorChannelCommunityCommentRecord.toReplyDomain(): CreatorChannelCommunityReply {
return CreatorChannelCommunityReply(
commentId = commentId,
writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
content = content,
createdAt = createdAt
)
}
private fun String?.toSignedAudioUrl(): String? {
if (isNullOrBlank()) return null
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
@@ -135,5 +280,6 @@ class CreatorChannelCommunityQueryService(
companion object {
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
private const val INVALID_COMMUNITY_REQUEST_MESSAGE_KEY = "creator.community.invalid_request_retry"
}
}

View File

@@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
@@ -173,6 +175,119 @@ class CreatorChannelCommunityQueryServiceTest {
assertEquals(true, posts.single().isLiked)
}
@Test
@DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다")
fun shouldAssembleCommunityPostDetailWithInitialComments() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPost = communityPostRecord(1L, price = 0, isLiked = true)
communityCommentCount = 21
communityComments = (1L..21L).map { communityCommentRecord(it) }
latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 1L, writerNickname = "deleted_reply-writer"))
}
val service = createService(port)
val viewer = createMember(id = 10L)
val detail = service.getCommunityPostDetail(1L, viewer, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(1L, detail.post.postId)
assertEquals(true, detail.post.isLiked)
assertEquals(21, detail.comments.commentCount)
assertEquals(20, detail.comments.comments.size)
assertEquals(true, detail.comments.hasNext)
assertEquals("reply-writer", detail.comments.comments.first().latestReply?.writerNickname)
assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl)
}
@Test
@DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다")
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPost = communityPostRecord(1L, price = 0, isCommentAvailable = false)
}
val service = createService(port)
val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(0, comments.commentCount)
assertEquals(0, comments.comments.size)
assertEquals(0, comments.page.page)
assertEquals(50, comments.page.size)
assertEquals(false, comments.hasNext)
assertEquals(false, port.commentListCalled)
}
@Test
@DisplayName("커뮤니티 댓글은 page fallback과 최신 답글을 적용한다")
fun shouldApplyCommentPageFallbackAndLatestReply() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPost = communityPostRecord(1L, price = 0)
communityCommentCount = 60
communityComments = (1L..51L).map { communityCommentRecord(it, writerProfilePath = "profile/$it.png") }
latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 2L))
}
val service = createService(port)
val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(0L, port.commentListOffset)
assertEquals(51, port.commentListLimit)
assertEquals((1L..50L).toList(), port.latestReplyCommentIds)
assertEquals(50, comments.comments.size)
assertEquals(true, comments.hasNext)
assertEquals("https://cdn.test/profile/1.png", comments.comments.first().writerProfileImageUrl)
assertEquals(100L, comments.comments[1].latestReply?.commentId)
}
@Test
@DisplayName("커뮤니티 답글은 부모 댓글 context를 확인하고 page fallback으로 조립한다")
fun shouldAssembleCommunityReplies() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, true)
communityReplyCount = 51
communityReplies = (1L..51L).map { communityCommentRecord(it, parentCommentId = 1L) }
}
val service = createService(port)
val replies = service.getCommunityReplies(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(51, replies.replyCount)
assertEquals(50, replies.replies.size)
assertEquals(0L, port.replyListOffset)
assertEquals(51, port.replyListLimit)
assertEquals(true, replies.hasNext)
}
@Test
@DisplayName("부모 댓글 context가 보이지 않으면 커뮤니티 답글 조회는 예외를 던진다")
fun shouldThrowWhenRootCommentContextNotVisible() {
val port = FakeCreatorChannelCommunityQueryPort().apply { rootCommentContext = null }
val service = createService(port)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0))
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("댓글 불가 게시글의 답글 직접 조회는 빈 응답을 반환한다")
fun shouldReturnEmptyRepliesWhenCommentUnavailable() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, false)
communityReplyCount = 1
communityReplies = listOf(communityCommentRecord(2L, parentCommentId = 1L))
}
val service = createService(port)
val replies = service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(0, replies.replyCount)
assertEquals(0, replies.replies.size)
assertEquals(false, replies.hasNext)
assertEquals(null, port.replyListOffset)
}
private fun createService(
port: FakeCreatorChannelCommunityQueryPort,
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
@@ -226,11 +341,29 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
var blocked = false
var communityPostCount = 1
var communityPosts = listOf(communityPostRecord(1L, price = 0))
var communityPost: CreatorChannelCommunityPostRecord? = communityPostRecord(1L, price = 0)
var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
var communityCommentCount = 0
var communityComments = emptyList<CreatorChannelCommunityCommentRecord>()
var latestReplies = emptyList<CreatorChannelCommunityCommentRecord>()
var rootCommentContext: CreatorChannelCommunityCommentContextRecord? = CreatorChannelCommunityCommentContextRecord(
commentId = 1L,
postId = 1L,
creatorId = 1L,
isCommentAvailable = true
)
var communityReplyCount = 0
var communityReplies = emptyList<CreatorChannelCommunityCommentRecord>()
var countCanViewAdultContent: Boolean? = null
var listCanViewAdultContent: Boolean? = null
var listOffset: Long? = null
var listLimit: Int? = null
var commentListCalled = false
var commentListOffset: Long? = null
var commentListLimit: Int? = null
var latestReplyCommentIds = emptyList<Long>()
var replyListOffset: Long? = null
var replyListLimit: Int? = null
var homeCreatorId: Long? = null
var homeViewerId: Long? = null
var homeIsPinned: Boolean? = null
@@ -277,6 +410,54 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
homeLimit = limit
return homeCommunityPosts
}
override fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord? = communityPost
override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = communityCommentCount
override fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord> {
commentListCalled = true
commentListOffset = offset
commentListLimit = limit
return communityComments
}
override fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
): List<CreatorChannelCommunityCommentRecord> {
latestReplyCommentIds = commentIds
return latestReplies
}
override fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityCommentContextRecord? = rootCommentContext
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = communityReplyCount
override fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord> {
replyListOffset = offset
replyListLimit = limit
return communityReplies
}
}
private fun communityPostRecord(
@@ -287,7 +468,8 @@ private fun communityPostRecord(
creatorProfilePath: String? = "profile/$postId.png",
imagePath: String? = "image/$postId.png",
audioPath: String? = "audio/$postId.mp3",
isLiked: Boolean = false
isLiked: Boolean = false,
isCommentAvailable: Boolean = true
): CreatorChannelCommunityPostRecord {
return CreatorChannelCommunityPostRecord(
postId = postId,
@@ -300,10 +482,28 @@ private fun communityPostRecord(
price = price,
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId),
existOrdered = existOrdered,
isCommentAvailable = true,
isCommentAvailable = isCommentAvailable,
likeCount = postId.toInt(),
commentCount = postId.toInt() + 1,
isPinned = postId == 1L,
isLiked = isLiked
)
}
private fun communityCommentRecord(
commentId: Long,
parentCommentId: Long? = null,
writerProfilePath: String? = null,
writerNickname: String = "writer-$commentId"
): CreatorChannelCommunityCommentRecord {
return CreatorChannelCommunityCommentRecord(
commentId = commentId,
parentCommentId = parentCommentId,
postId = 1L,
writerId = commentId + 100L,
writerProfilePath = writerProfilePath,
writerNickname = writerNickname,
content = "comment-$commentId",
createdAt = LocalDateTime.of(2026, 7, 6, 10, 0).plusMinutes(commentId)
)
}

View File

@@ -742,4 +742,40 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
)
)
}
override fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord? = null
override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = 0
override fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
override fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
override fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord? = null
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = 0
override fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
}