test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
2 changed files with 454 additions and 0 deletions
Showing only changes of commit 0620e54cbd - Show all commits

View File

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

View File

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