test #426
@@ -0,0 +1,140 @@
|
||||
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.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.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
|
||||
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
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class CreatorChannelCommunityQueryService(
|
||||
private val queryPortProvider: ObjectProvider<CreatorChannelCommunityQueryPort>,
|
||||
private val queryPolicy: CreatorChannelCommunityQueryPolicy,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
private val audioContentCloudFront: AudioContentCloudFront,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
fun getCommunityTab(
|
||||
creatorId: Long,
|
||||
viewer: Member,
|
||||
page: Int?,
|
||||
size: Int?,
|
||||
now: LocalDateTime = LocalDateTime.now()
|
||||
): CreatorChannelCommunityTab {
|
||||
val communityPage = queryPolicy.createPage(page, size)
|
||||
val queryPort = queryPortProvider.getObject()
|
||||
val viewerId = viewer.id!!
|
||||
val creator = queryPort.findCreator(creatorId, viewerId)
|
||||
?: throw SodaException(messageKey = "member.validation.user_not_found")
|
||||
|
||||
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
|
||||
val messageTemplate = messageSource
|
||||
.getMessage("explorer.creator.blocked_access", langContext.lang)
|
||||
.orEmpty()
|
||||
throw SodaException(message = String.format(messageTemplate, creator.nickname))
|
||||
}
|
||||
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
|
||||
val fetchedPosts = queryPort.findCommunityPosts(
|
||||
creatorId = creatorId,
|
||||
viewerId = viewerId,
|
||||
canViewAdultContent = canViewAdultContent,
|
||||
offset = communityPage.offset,
|
||||
limit = communityPage.fetchLimit
|
||||
)
|
||||
|
||||
return CreatorChannelCommunityTab(
|
||||
communityPostCount = queryPort.countCommunityPosts(
|
||||
creatorId = creatorId,
|
||||
viewerId = viewerId,
|
||||
canViewAdultContent = canViewAdultContent
|
||||
),
|
||||
communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) },
|
||||
page = communityPage,
|
||||
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
|
||||
)
|
||||
}
|
||||
|
||||
fun findHomeCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
isPinned: Boolean,
|
||||
canViewAdultContent: Boolean,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPost> {
|
||||
return queryPortProvider.getObject()
|
||||
.findHomeCommunityPosts(
|
||||
creatorId = creatorId,
|
||||
viewerId = viewerId,
|
||||
isPinned = isPinned,
|
||||
canViewAdultContent = canViewAdultContent,
|
||||
limit = limit
|
||||
)
|
||||
.map { it.toDomain(viewerId) }
|
||||
}
|
||||
|
||||
private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) {
|
||||
when (creator.role) {
|
||||
MemberRole.CREATOR -> return
|
||||
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost {
|
||||
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
|
||||
return CreatorChannelCommunityPost(
|
||||
postId = postId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileUrl = creatorProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||
imageUrl = if (canAccessPaidContent) imagePath.toCdnUrl(cloudFrontHost) else null,
|
||||
audioUrl = if (canAccessPaidContent) audioPath.toSignedAudioUrl() else null,
|
||||
content = queryPolicy.maskPaidContent(
|
||||
content = content,
|
||||
price = price,
|
||||
isCreatorSelf = viewerId == creatorId,
|
||||
existOrdered = existOrdered
|
||||
),
|
||||
price = price,
|
||||
createdAt = createdAt,
|
||||
existOrdered = existOrdered || viewerId == creatorId,
|
||||
isCommentAvailable = isCommentAvailable,
|
||||
likeCount = likeCount,
|
||||
commentCount = commentCount,
|
||||
isPinned = isPinned
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.toSignedAudioUrl(): String? {
|
||||
if (isNullOrBlank()) return null
|
||||
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
|
||||
}
|
||||
|
||||
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
|
||||
|
||||
companion object {
|
||||
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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.content.ContentType
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
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.MemberProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
|
||||
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
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class CreatorChannelCommunityQueryServiceTest {
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
|
||||
fun shouldResolveRequestFallbacksAndAssembleCommunityTab() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
communityPostCount = 60
|
||||
communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) }
|
||||
}
|
||||
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/1.mp3", 1000 * 60 * 30))
|
||||
.thenReturn("https://signed.test/audio/1.mp3")
|
||||
val service = createService(port, audioContentCloudFront, canViewAdultContent = false)
|
||||
val viewer = createMember(id = 10L)
|
||||
val now = LocalDateTime.of(2026, 6, 21, 10, 0)
|
||||
|
||||
val tab = service.getCommunityTab(
|
||||
creatorId = 1L,
|
||||
viewer = viewer,
|
||||
page = -1,
|
||||
size = 100,
|
||||
now = now
|
||||
)
|
||||
|
||||
assertEquals(60, tab.communityPostCount)
|
||||
assertEquals(0, tab.page.page)
|
||||
assertEquals(50, tab.page.size)
|
||||
assertEquals(0L, port.listOffset)
|
||||
assertEquals(51, port.listLimit)
|
||||
assertEquals(false, port.listCanViewAdultContent)
|
||||
assertEquals(false, port.countCanViewAdultContent)
|
||||
assertEquals(50, tab.communityPosts.size)
|
||||
assertTrue(tab.hasNext)
|
||||
assertEquals("https://cdn.test/profile/1.png", tab.communityPosts.first().creatorProfileUrl)
|
||||
assertEquals("https://cdn.test/image/1.png", tab.communityPosts.first().imageUrl)
|
||||
assertEquals("https://signed.test/audio/1.mp3", tab.communityPosts.first().audioUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
|
||||
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply { creator = null }
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 10L)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||
}
|
||||
|
||||
assertEquals("member.validation.user_not_found", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
|
||||
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 10L)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||
}
|
||||
|
||||
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
|
||||
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply { blocked = true }
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 10L)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||
}
|
||||
|
||||
assertNull(exception.messageKey)
|
||||
assertEquals("Channel access is restricted at creator's request.", exception.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 게시글은 접근 권한에 따라 이미지와 오디오와 본문을 조립한다")
|
||||
fun shouldAssembleCommunityPostAssetsByAccessPolicy() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
communityPosts = listOf(
|
||||
communityPostRecord(1L, price = 0, existOrdered = false),
|
||||
communityPostRecord(2L, price = 100, existOrdered = true),
|
||||
communityPostRecord(3L, price = 100, existOrdered = false),
|
||||
communityPostRecord(4L, creatorId = 10L, price = 100, existOrdered = false, creatorProfilePath = null),
|
||||
communityPostRecord(5L, price = 0, imagePath = " ", audioPath = null)
|
||||
)
|
||||
}
|
||||
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/1.mp3", 1000 * 60 * 30))
|
||||
.thenReturn("https://signed.test/audio/1.mp3")
|
||||
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/2.mp3", 1000 * 60 * 30))
|
||||
.thenReturn("https://signed.test/audio/2.mp3")
|
||||
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/4.mp3", 1000 * 60 * 30))
|
||||
.thenReturn("https://signed.test/audio/4.mp3")
|
||||
val service = createService(port, audioContentCloudFront)
|
||||
val viewer = createMember(id = 10L)
|
||||
|
||||
val posts = service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||
.communityPosts
|
||||
|
||||
assertEquals("https://cdn.test/image/1.png", posts[0].imageUrl)
|
||||
assertEquals("https://signed.test/audio/1.mp3", posts[0].audioUrl)
|
||||
assertEquals("content-1", posts[0].content)
|
||||
assertEquals("https://cdn.test/image/2.png", posts[1].imageUrl)
|
||||
assertEquals("https://signed.test/audio/2.mp3", posts[1].audioUrl)
|
||||
assertNull(posts[2].imageUrl)
|
||||
assertNull(posts[2].audioUrl)
|
||||
assertEquals("cont...", posts[2].content)
|
||||
assertEquals("https://cdn.test/image/4.png", posts[3].imageUrl)
|
||||
assertEquals("https://signed.test/audio/4.mp3", posts[3].audioUrl)
|
||||
assertEquals("https://cdn.test/profile/default-profile.png", posts[3].creatorProfileUrl)
|
||||
assertEquals(true, posts[3].existOrdered)
|
||||
assertNull(posts[4].imageUrl)
|
||||
assertNull(posts[4].audioUrl)
|
||||
Mockito.verify(audioContentCloudFront, Mockito.never()).generateSignedURL("audio/3.mp3", 1000 * 60 * 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 커뮤니티 요약 조회는 탭 전체 검증 없이 받은 조건으로 목록을 조립한다")
|
||||
fun shouldAssembleHomeCommunityPostsWithoutTabValidation() {
|
||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||
creator = null
|
||||
blocked = true
|
||||
homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
|
||||
}
|
||||
val service = createService(port)
|
||||
|
||||
val posts = service.findHomeCommunityPosts(
|
||||
creatorId = 1L,
|
||||
viewerId = 10L,
|
||||
isPinned = true,
|
||||
canViewAdultContent = false,
|
||||
limit = 3
|
||||
)
|
||||
|
||||
assertEquals(1, posts.size)
|
||||
assertEquals(1L, port.homeCreatorId)
|
||||
assertEquals(10L, port.homeViewerId)
|
||||
assertEquals(true, port.homeIsPinned)
|
||||
assertEquals(false, port.homeCanViewAdultContent)
|
||||
assertEquals(3, port.homeLimit)
|
||||
}
|
||||
|
||||
private fun createService(
|
||||
port: FakeCreatorChannelCommunityQueryPort,
|
||||
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
canViewAdultContent: Boolean = true
|
||||
): CreatorChannelCommunityQueryService {
|
||||
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
Mockito.`when`(
|
||||
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(
|
||||
ViewerContentPreference(
|
||||
countryCode = "US",
|
||||
isAdultContentVisible = canViewAdultContent,
|
||||
contentType = ContentType.ALL,
|
||||
isAdult = canViewAdultContent
|
||||
)
|
||||
)
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.EN)
|
||||
return CreatorChannelCommunityQueryService(
|
||||
queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port),
|
||||
queryPolicy = CreatorChannelCommunityQueryPolicy(),
|
||||
memberContentPreferenceService = preferenceService,
|
||||
audioContentCloudFront = audioContentCloudFront,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = langContext,
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMember(id: Long): Member {
|
||||
return Member(
|
||||
email = "member$id@test.com",
|
||||
password = "password",
|
||||
nickname = "member$id",
|
||||
provider = MemberProvider.EMAIL
|
||||
).apply { this.id = id }
|
||||
}
|
||||
}
|
||||
|
||||
private class FixedCreatorChannelCommunityQueryPortProvider(
|
||||
private val port: CreatorChannelCommunityQueryPort
|
||||
) : ObjectProvider<CreatorChannelCommunityQueryPort> {
|
||||
override fun getObject(vararg args: Any?): CreatorChannelCommunityQueryPort = port
|
||||
|
||||
override fun getIfAvailable(): CreatorChannelCommunityQueryPort = port
|
||||
|
||||
override fun getIfUnique(): CreatorChannelCommunityQueryPort = port
|
||||
|
||||
override fun getObject(): CreatorChannelCommunityQueryPort = port
|
||||
}
|
||||
|
||||
private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQueryPort {
|
||||
var creator: CreatorChannelCommunityCreatorRecord? = CreatorChannelCommunityCreatorRecord(
|
||||
creatorId = 1L,
|
||||
role = MemberRole.CREATOR,
|
||||
nickname = "creator"
|
||||
)
|
||||
var blocked = false
|
||||
var communityPostCount = 1
|
||||
var communityPosts = listOf(communityPostRecord(1L, price = 0))
|
||||
var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
|
||||
var countCanViewAdultContent: Boolean? = null
|
||||
var listCanViewAdultContent: Boolean? = null
|
||||
var listOffset: Long? = null
|
||||
var listLimit: Int? = null
|
||||
var homeCreatorId: Long? = null
|
||||
var homeViewerId: Long? = null
|
||||
var homeIsPinned: Boolean? = null
|
||||
var homeCanViewAdultContent: Boolean? = null
|
||||
var homeLimit: Int? = null
|
||||
|
||||
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? = creator
|
||||
|
||||
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
|
||||
|
||||
override fun countCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
canViewAdultContent: Boolean
|
||||
): Int {
|
||||
countCanViewAdultContent = canViewAdultContent
|
||||
return communityPostCount
|
||||
}
|
||||
|
||||
override fun findCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPostRecord> {
|
||||
listCanViewAdultContent = canViewAdultContent
|
||||
listOffset = offset
|
||||
listLimit = limit
|
||||
return communityPosts
|
||||
}
|
||||
|
||||
override fun findHomeCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
isPinned: Boolean,
|
||||
canViewAdultContent: Boolean,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPostRecord> {
|
||||
homeCreatorId = creatorId
|
||||
homeViewerId = viewerId
|
||||
homeIsPinned = isPinned
|
||||
homeCanViewAdultContent = canViewAdultContent
|
||||
homeLimit = limit
|
||||
return homeCommunityPosts
|
||||
}
|
||||
}
|
||||
|
||||
private fun communityPostRecord(
|
||||
postId: Long,
|
||||
creatorId: Long = 1L,
|
||||
price: Int,
|
||||
existOrdered: Boolean = false,
|
||||
creatorProfilePath: String? = "profile/$postId.png",
|
||||
imagePath: String? = "image/$postId.png",
|
||||
audioPath: String? = "audio/$postId.mp3"
|
||||
): CreatorChannelCommunityPostRecord {
|
||||
return CreatorChannelCommunityPostRecord(
|
||||
postId = postId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = "creator-$creatorId",
|
||||
creatorProfilePath = creatorProfilePath,
|
||||
imagePath = imagePath,
|
||||
audioPath = audioPath,
|
||||
content = "content-$postId",
|
||||
price = price,
|
||||
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId),
|
||||
existOrdered = existOrdered,
|
||||
isCommentAvailable = true,
|
||||
likeCount = postId.toInt(),
|
||||
commentCount = postId.toInt() + 1,
|
||||
isPinned = postId == 1L
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user