feat(creator-channel): 오디오 탭 조회 서비스를 추가한다

This commit is contained in:
2026-06-19 16:06:45 +09:00
parent 80a06ad63d
commit 4fdb9bcb26
2 changed files with 379 additions and 99 deletions

View File

@@ -1,123 +1,263 @@
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)
}
private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? {
return CreatorChannelAudioCreatorRecord(
creatorId = creatorId,
role = MemberRole.CREATOR,
nickname = "creator"
@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"
)
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
return false
}
override fun findActiveThemeId(themeId: Long): Long? {
return themeId
}
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale))
}
override fun countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return 1
}
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return 1
}
override fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return 1
}
override fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
return emptyList()
}
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 {
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 findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? = creator
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))
}
override fun countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int = audioContentCount
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int = paidAudioContentCount
override fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int = purchasedAudioContentCount
override fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
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
)
}