test #426

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

View File

@@ -0,0 +1,115 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
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.series.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
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 CreatorChannelSeriesQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelSeriesQueryPort>,
private val queryPolicy: CreatorChannelSeriesQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getSeriesTab(
creatorId: Long,
viewer: Member,
sort: String?,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelSeriesTab {
val resolvedSort = queryPolicy.resolveSort(sort)
val seriesPage = 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 locale = langContext.lang.code
val fetchedSeries = queryPort.findSeries(
creatorId = creatorId,
viewerId = viewerId,
now = now,
canViewAdultContent = canViewAdultContent,
sort = resolvedSort,
locale = locale,
offset = seriesPage.offset,
limit = seriesPage.fetchLimit
)
return CreatorChannelSeriesTab(
seriesCount = queryPort.countSeries(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent
),
series = queryPolicy.limitItems(fetchedSeries, seriesPage).map { it.toDomain(creatorId, viewerId, locale) },
sort = resolvedSort,
page = seriesPage,
hasNext = queryPolicy.hasNext(fetchedSeries, seriesPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelSeriesCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun CreatorChannelSeriesRecord.toDomain(creatorId: Long, viewerId: Long, locale: String): CreatorChannelSeries {
val isCreatorSelf = viewerId == creatorId
val domainPurchasedContentCount = if (isCreatorSelf) null else purchasedContentCount
val domainPaidContentCount = if (isCreatorSelf) null else paidContentCount
return CreatorChannelSeries(
seriesId = seriesId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(cloudFrontHost),
publishedDaysOfWeek = queryPolicy.publishedDaysOfWeekText(publishedDaysOfWeek, locale),
isOriginal = isOriginal,
isAdult = isAdult,
isProceeding = state == SeriesState.PROCEEDING,
contentCount = contentCount,
purchasedContentCount = domainPurchasedContentCount,
paidContentCount = domainPaidContentCount,
purchasedPaidContentRate = if (isCreatorSelf) {
null
} else {
queryPolicy.purchaseRate(domainPaidContentCount ?: 0, domainPurchasedContentCount ?: 0)
}
)
}
}

View File

@@ -0,0 +1,246 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
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.series.domain.CreatorChannelSeriesQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
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 CreatorChannelSeriesQueryServiceTest {
@Test
@DisplayName("시리즈 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
fun shouldResolveRequestFallbacksAndAssembleSeriesTab() {
val port = FakeCreatorChannelSeriesQueryPort().apply {
series = (1L..51L).map { seriesRecord(it) }
}
val service = createService(port, canViewAdultContent = false)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 20, 10, 0)
val tab = service.getSeriesTab(
creatorId = 1L,
viewer = viewer,
sort = "UNKNOWN",
page = -1,
size = 100,
now = now
)
assertEquals(ContentSort.LATEST, tab.sort)
assertEquals(0, tab.page.page)
assertEquals(50, tab.page.size)
assertEquals(0L, port.listOffset)
assertEquals(51, port.listLimit)
assertEquals(ContentSort.LATEST, port.listSort)
assertEquals("en", port.listLocale)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(60, tab.seriesCount)
assertEquals(50, tab.series.size)
assertTrue(tab.hasNext)
assertEquals("https://cdn.test/cover/1.png", tab.series.first().coverImageUrl)
assertEquals("Every Mon, Thu", tab.series.first().publishedDaysOfWeek)
assertEquals(75, tab.series.first().purchasedPaidContentRate)
}
@Test
@DisplayName("조회자가 creator 본인이면 시리즈 구매 통계 필드는 null이다")
fun shouldHidePurchaseStatsForCreatorSelf() {
val port = FakeCreatorChannelSeriesQueryPort().apply {
series = listOf(seriesRecord(1L))
}
val service = createService(port)
val viewer = createMember(id = 1L)
val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
assertNull(tab.series.first().purchasedContentCount)
assertNull(tab.series.first().paidContentCount)
assertNull(tab.series.first().purchasedPaidContentRate)
}
@Test
@DisplayName("blank cover와 0개 유료 콘텐츠 구매율은 null cover와 0으로 조립한다")
fun shouldAssembleBlankCoverAndZeroPurchaseRate() {
val port = FakeCreatorChannelSeriesQueryPort().apply {
series = listOf(seriesRecord(1L, coverImagePath = " ", paidContentCount = 0, purchasedContentCount = 3))
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
assertNull(tab.series.first().coverImageUrl)
assertEquals(0, tab.series.first().purchasedPaidContentRate)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelSeriesQueryPort().apply { creator = null }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelSeriesQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelSeriesQueryPort().apply { blocked = true }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("Channel access is restricted at creator's request.", exception.message)
}
private fun createService(
port: FakeCreatorChannelSeriesQueryPort,
canViewAdultContent: Boolean = true
): CreatorChannelSeriesQueryService {
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 CreatorChannelSeriesQueryService(
queryPortProvider = FixedCreatorChannelSeriesQueryPortProvider(port),
queryPolicy = CreatorChannelSeriesQueryPolicy(),
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 FixedCreatorChannelSeriesQueryPortProvider(
private val port: CreatorChannelSeriesQueryPort
) : ObjectProvider<CreatorChannelSeriesQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelSeriesQueryPort = port
override fun getIfAvailable(): CreatorChannelSeriesQueryPort = port
override fun getIfUnique(): CreatorChannelSeriesQueryPort = port
override fun getObject(): CreatorChannelSeriesQueryPort = port
}
private class FakeCreatorChannelSeriesQueryPort : CreatorChannelSeriesQueryPort {
var creator: CreatorChannelSeriesCreatorRecord? = CreatorChannelSeriesCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var seriesCount = 60
var series = (1L..21L).map { seriesRecord(it) }
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?): CreatorChannelSeriesCreatorRecord? = creator
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int = seriesCount
override fun findSeries(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelSeriesRecord> {
listSort = sort
listLocale = locale
listOffset = offset
listLimit = limit
listCanViewAdultContent = canViewAdultContent
return series
}
}
private fun seriesRecord(
seriesId: Long,
coverImagePath: String? = "cover/$seriesId.png",
paidContentCount: Int? = 4,
purchasedContentCount: Int? = 3
): CreatorChannelSeriesRecord {
return CreatorChannelSeriesRecord(
seriesId = seriesId,
title = "series-$seriesId",
coverImagePath = coverImagePath,
publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU),
isOriginal = true,
isAdult = false,
state = SeriesState.PROCEEDING,
contentCount = 5,
purchasedContentCount = purchasedContentCount,
paidContentCount = paidContentCount
)
}