test #426

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

View File

@@ -0,0 +1,140 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.application
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.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
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 CreatorChannelAudioQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelAudioQueryPort>,
private val queryPolicy: CreatorChannelAudioQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getAudioTab(
creatorId: Long,
viewer: Member,
sort: String?,
themeId: Long?,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelAudioTab {
val resolvedSort = queryPolicy.resolveSort(sort)
val audioPage = 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 resolvedThemeId = themeId?.let(queryPort::findActiveThemeId)
val locale = langContext.lang.code
val fetchedContents = queryPort.findAudioContents(
creatorId = creatorId,
viewerId = viewerId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent,
sort = resolvedSort,
locale = locale,
offset = audioPage.offset,
limit = audioPage.fetchLimit
)
val paidAudioContentCount = queryPort.countPaidAudioContents(
creatorId = creatorId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent
)
val purchasedAudioContentCount = queryPort.countPurchasedAudioContents(
creatorId = creatorId,
viewerId = viewerId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent
)
return CreatorChannelAudioTab(
audioContentCount = queryPort.countAudioContents(
creatorId = creatorId,
themeId = resolvedThemeId,
now = now,
canViewAdultContent = canViewAdultContent
),
paidAudioContentCount = paidAudioContentCount,
purchasedAudioContentCount = purchasedAudioContentCount,
purchasedAudioContentRate = queryPolicy.purchaseRate(paidAudioContentCount, purchasedAudioContentCount),
themes = queryPort.findAudioThemes(locale).map { it.toDomain() },
audioContents = queryPolicy.limitItems(fetchedContents, audioPage).map { it.toDomain() },
sort = resolvedSort,
themeId = resolvedThemeId,
page = audioPage,
hasNext = queryPolicy.hasNext(fetchedContents, audioPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelAudioCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun CreatorChannelAudioThemeRecord.toDomain() = CreatorChannelAudioTheme(
themeId = themeId,
themeName = themeName
)
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries,
isOwned = isOwned,
isRented = isRented
)
private fun String?.toCdnUrl(): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}
}

View File

@@ -1,78 +1,202 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.application
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.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
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 CreatorChannelAudioQueryServiceTest {
@Test
@DisplayName("오디오 탭 domain model과 port 계약을 사용할 수 있")
fun shouldUseAudioTabDomainModelAndPortContract() {
val page = CreatorChannelPage(page = 0, size = 20)
val tab = CreatorChannelAudioTab(
audioContentCount = 1,
paidAudioContentCount = 1,
purchasedAudioContentCount = 1,
purchasedAudioContentRate = 100.0,
themes = listOf(CreatorChannelAudioTheme(themeId = 10L, themeName = "theme")),
audioContents = listOf(audioContent()),
sort = ContentSort.LATEST,
themeId = 10L,
page = page,
hasNext = false
)
val port = FakeCreatorChannelAudioQueryPort()
@DisplayName("오디오 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한")
fun shouldResolveRequestFallbacksAndAssembleAudioTab() {
val port = FakeCreatorChannelAudioQueryPort().apply {
activeThemeId = null
paidAudioContentCount = 4
purchasedAudioContentCount = 3
audioContents = (1L..51L).map { audioContentRecord(it) }
}
val service = createService(port, canViewAdultContent = false)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 19, 10, 0)
assertEquals(1, tab.audioContentCount)
assertEquals(10L, tab.themes.first().themeId)
assertEquals(100L, tab.audioContents.first().audioContentId)
assertEquals(MemberRole.CREATOR, port.findCreator(creatorId = 1L, viewerId = 2L)?.role)
val tab = service.getAudioTab(
creatorId = 1L,
viewer = viewer,
sort = "UNKNOWN",
themeId = 999L,
page = -1,
size = 100,
now = now
)
assertEquals(ContentSort.LATEST, tab.sort)
assertNull(tab.themeId)
assertEquals(0, tab.page.page)
assertEquals(50, tab.page.size)
assertEquals(0L, port.listOffset)
assertEquals(51, port.listLimit)
assertEquals(ContentSort.LATEST, port.listSort)
assertNull(port.listThemeId)
assertEquals("en", port.listLocale)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(75.0, tab.purchasedAudioContentRate)
assertEquals(50, tab.audioContents.size)
assertTrue(tab.hasNext)
assertEquals("https://cdn.test/audio/1.png", tab.audioContents.first().imageUrl)
}
private fun audioContent(): CreatorChannelAudioContent {
return CreatorChannelAudioContent(
audioContentId = 100L,
title = "audio",
duration = "00:01:00",
imageUrl = null,
price = 10,
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
seriesName = null,
isOriginalSeries = null,
isOwned = true,
isRented = false
@Test
@DisplayName("유료 오디오 콘텐츠가 없으면 소장률은 0.0이다")
fun shouldReturnZeroPurchaseRateWhenPaidContentCountIsZero() {
val port = FakeCreatorChannelAudioQueryPort().apply {
paidAudioContentCount = 0
purchasedAudioContentCount = 3
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
assertEquals(0.0, tab.purchasedAudioContentRate)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelAudioQueryPort().apply { creator = null }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelAudioQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelAudioQueryPort().apply { blocked = true }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("Channel access is restricted at creator's request.", exception.message)
}
private fun createService(
port: FakeCreatorChannelAudioQueryPort,
canViewAdultContent: Boolean = true
): CreatorChannelAudioQueryService {
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 CreatorChannelAudioQueryService(
queryPortProvider = FixedCreatorChannelAudioQueryPortProvider(port),
queryPolicy = CreatorChannelAudioQueryPolicy(),
memberContentPreferenceService = preferenceService,
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 FixedCreatorChannelAudioQueryPortProvider(
private val port: CreatorChannelAudioQueryPort
) : ObjectProvider<CreatorChannelAudioQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelAudioQueryPort = port
override fun getIfAvailable(): CreatorChannelAudioQueryPort = port
override fun getIfUnique(): CreatorChannelAudioQueryPort = port
override fun getObject(): CreatorChannelAudioQueryPort = port
}
private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? {
return CreatorChannelAudioCreatorRecord(
creatorId = creatorId,
var creator: CreatorChannelAudioCreatorRecord? = CreatorChannelAudioCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
}
var blocked = false
var activeThemeId: Long? = 10L
var audioContentCount = 60
var paidAudioContentCount = 4
var purchasedAudioContentCount = 3
var audioContents = (1L..21L).map { audioContentRecord(it) }
var listThemeId: Long? = null
var listSort: ContentSort? = null
var listLocale: String? = null
var listOffset: Long? = null
var listLimit: Int? = null
var listCanViewAdultContent: Boolean? = null
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
return false
}
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? = creator
override fun findActiveThemeId(themeId: Long): Long? {
return themeId
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun findActiveThemeId(themeId: Long): Long? = activeThemeId
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale))
@@ -83,18 +207,14 @@ class CreatorChannelAudioQueryServiceTest {
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return 1
}
): Int = audioContentCount
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return 1
}
): Int = paidAudioContentCount
override fun countPurchasedAudioContents(
creatorId: Long,
@@ -102,9 +222,7 @@ class CreatorChannelAudioQueryServiceTest {
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return 1
}
): Int = purchasedAudioContentCount
override fun findAudioContents(
creatorId: Long,
@@ -117,7 +235,29 @@ class CreatorChannelAudioQueryServiceTest {
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
return emptyList()
listThemeId = themeId
listSort = sort
listLocale = locale
listOffset = offset
listLimit = limit
listCanViewAdultContent = canViewAdultContent
return audioContents
}
}
private fun audioContentRecord(audioContentId: Long): CreatorChannelAudioContentRecord {
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = "audio-$audioContentId",
duration = "00:10:00",
imagePath = "audio/$audioContentId.png",
price = 10,
isAdult = false,
isPointAvailable = true,
isFirstContent = audioContentId == 1L,
seriesName = "series",
isOriginalSeries = true,
isOwned = audioContentId == 1L,
isRented = audioContentId == 2L
)
}