feat(creator): 채널 홈 조회 서비스를 추가한다
This commit is contained in:
@@ -0,0 +1,265 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.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.Gender
|
||||||
|
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.domain.CreatorChannelActivity
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
|
||||||
|
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 CreatorChannelHomeQueryService(
|
||||||
|
private val queryPort: CreatorChannelHomeQueryPort,
|
||||||
|
private val queryPolicy: CreatorChannelHomeQueryPolicy,
|
||||||
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
fun getHome(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelHome {
|
||||||
|
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 isViewerCreator = viewerId == creatorId
|
||||||
|
val effectiveViewerGender = viewer.effectiveGender()
|
||||||
|
val latestAudioContent = queryPort
|
||||||
|
.findLatestAudioContent(creatorId, now, canViewAdultContent)
|
||||||
|
?.toDomain()
|
||||||
|
val audioContents = queryPolicy.excludeLatestAudioContent(
|
||||||
|
queryPort.findAudioContents(
|
||||||
|
creatorId = creatorId,
|
||||||
|
now = now,
|
||||||
|
latestAudioContentId = latestAudioContent?.audioContentId,
|
||||||
|
canViewAdultContent = canViewAdultContent
|
||||||
|
).map { it.toDomain() },
|
||||||
|
latestAudioContent?.audioContentId
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreatorChannelHome(
|
||||||
|
creator = creator.toDomain(),
|
||||||
|
currentLive = queryPort.findCurrentLive(
|
||||||
|
creatorId = creatorId,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
viewerId = viewerId,
|
||||||
|
isViewerCreator = isViewerCreator,
|
||||||
|
effectiveViewerGender = effectiveViewerGender
|
||||||
|
)?.toDomain(),
|
||||||
|
latestAudioContent = latestAudioContent,
|
||||||
|
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now).map { it.toDomain() },
|
||||||
|
notices = queryPort.findCommunityPosts(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewerId = viewerId,
|
||||||
|
isFixed = true,
|
||||||
|
canViewAdultContent = canViewAdultContent
|
||||||
|
).map { it.toDomain() },
|
||||||
|
schedules = queryPolicy.limitSchedules(
|
||||||
|
queryPort.findSchedules(
|
||||||
|
creatorId = creatorId,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
viewerId = viewerId,
|
||||||
|
isViewerCreator = isViewerCreator,
|
||||||
|
effectiveViewerGender = effectiveViewerGender
|
||||||
|
).map { it.toDomain() },
|
||||||
|
now,
|
||||||
|
canViewAdultContent
|
||||||
|
),
|
||||||
|
audioContents = audioContents,
|
||||||
|
series = queryPort.findSeries(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewerId = viewerId,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = canViewAdultContent,
|
||||||
|
contentType = preference.contentType
|
||||||
|
).map { it.toDomain() },
|
||||||
|
communities = queryPort.findCommunityPosts(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewerId = viewerId,
|
||||||
|
isFixed = false,
|
||||||
|
canViewAdultContent = canViewAdultContent
|
||||||
|
).map { it.toDomain() },
|
||||||
|
fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId).toDomain(),
|
||||||
|
introduce = creator.introduce,
|
||||||
|
activity = queryPort.findActivity(creatorId, now).toDomain(),
|
||||||
|
sns = queryPort.findSns(creatorId).toDomain()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) {
|
||||||
|
when (creator.role) {
|
||||||
|
MemberRole.CREATOR -> return
|
||||||
|
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Member.effectiveGender(): Gender {
|
||||||
|
auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE }
|
||||||
|
return gender
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorChannelCreatorRecord.toDomain() = CreatorChannelCreator(
|
||||||
|
creatorId = creatorId,
|
||||||
|
characterId = characterId,
|
||||||
|
nickname = nickname,
|
||||||
|
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
|
||||||
|
followerCount = followerCount,
|
||||||
|
isAiChatAvailable = isAiChatAvailable,
|
||||||
|
isDmAvailable = isDmAvailable,
|
||||||
|
isFollow = isFollow,
|
||||||
|
isNotify = isNotify
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
|
||||||
|
liveId = liveId,
|
||||||
|
title = title,
|
||||||
|
coverImageUrl = coverImagePath.toCdnUrl(),
|
||||||
|
beginDateTime = beginDateTime,
|
||||||
|
price = price,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
|
||||||
|
audioContentId = audioContentId,
|
||||||
|
title = title,
|
||||||
|
duration = duration,
|
||||||
|
imageUrl = imagePath.toCdnUrl(),
|
||||||
|
price = price,
|
||||||
|
isAdult = isAdult,
|
||||||
|
isPointAvailable = isPointAvailable,
|
||||||
|
isFirstContent = isFirstContent,
|
||||||
|
publishedAt = publishedAt,
|
||||||
|
seriesName = seriesName,
|
||||||
|
isOriginalSeries = isOriginalSeries
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
|
||||||
|
nickname = nickname,
|
||||||
|
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
|
||||||
|
can = can,
|
||||||
|
message = message,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule(
|
||||||
|
scheduledAt = scheduledAt,
|
||||||
|
title = title,
|
||||||
|
type = type,
|
||||||
|
targetId = targetId,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelSeriesRecord.toDomain() = CreatorChannelSeries(
|
||||||
|
seriesId = seriesId,
|
||||||
|
title = title,
|
||||||
|
coverImageUrl = coverImagePath.toCdnUrl().orEmpty(),
|
||||||
|
numberOfContent = numberOfContent,
|
||||||
|
isNew = isNew,
|
||||||
|
isOriginal = isOriginal
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelCommunityPostRecord.toDomain() = CreatorChannelCommunityPost(
|
||||||
|
postId = postId,
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
creatorProfileUrl = creatorProfilePath.toCdnUrl() ?: defaultProfileImageUrl(),
|
||||||
|
imageUrl = imagePath.toCdnUrl(),
|
||||||
|
audioUrl = audioPath.toCdnUrl(),
|
||||||
|
content = content,
|
||||||
|
price = price,
|
||||||
|
date = date,
|
||||||
|
existOrdered = existOrdered,
|
||||||
|
likeCount = likeCount,
|
||||||
|
commentCount = commentCount
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelFanTalkSummaryRecord.toDomain() = CreatorChannelFanTalkSummary(
|
||||||
|
totalCount = totalCount,
|
||||||
|
latestFanTalk = latestFanTalk?.toDomain()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelFanTalkRecord.toDomain() = CreatorChannelFanTalk(
|
||||||
|
fanTalkId = fanTalkId,
|
||||||
|
memberId = memberId,
|
||||||
|
nickname = nickname,
|
||||||
|
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
|
||||||
|
content = content,
|
||||||
|
languageCode = languageCode,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelActivityRecord.toDomain() = CreatorChannelActivity(
|
||||||
|
debutDate = debutDate,
|
||||||
|
dDay = dDay,
|
||||||
|
liveCount = liveCount,
|
||||||
|
liveDurationHours = liveDurationHours,
|
||||||
|
liveContributorCount = liveContributorCount,
|
||||||
|
audioContentCount = audioContentCount,
|
||||||
|
seriesCount = seriesCount
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelSnsRecord.toDomain() = CreatorChannelSns(
|
||||||
|
instagramUrl = instagramUrl,
|
||||||
|
fancimmUrl = fancimmUrl,
|
||||||
|
xUrl = xUrl,
|
||||||
|
youtubeUrl = youtubeUrl,
|
||||||
|
kakaoOpenChatUrl = kakaoOpenChatUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String?.toCdnUrl(): String? {
|
||||||
|
if (isNullOrBlank()) return null
|
||||||
|
if (startsWith("https://") || startsWith("http://")) return this
|
||||||
|
return "$cloudFrontHost/$this"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.creator.channel.domain
|
package kr.co.vividnext.sodalive.v2.creator.channel.domain
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Component
|
||||||
class CreatorChannelHomeQueryPolicy {
|
class CreatorChannelHomeQueryPolicy {
|
||||||
fun limitSchedules(
|
fun limitSchedules(
|
||||||
schedules: List<CreatorChannelSchedule>,
|
schedules: List<CreatorChannelSchedule>,
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.creator.channel.application
|
package kr.co.vividnext.sodalive.v2.creator.channel.application
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
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.Gender
|
||||||
|
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.auth.Auth
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
|
||||||
@@ -10,22 +22,152 @@ import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
|
|||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
|
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class CreatorChannelHomeQueryServiceTest {
|
class CreatorChannelHomeQueryServiceTest {
|
||||||
private val objectMapper = jacksonObjectMapper()
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 홈 서비스는 모든 섹션을 조립하고 최종 정책을 적용한다")
|
||||||
|
fun shouldAssembleCreatorChannelHomeWithFinalPolicies() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort()
|
||||||
|
val service = createService(port, canViewAdultContent = false)
|
||||||
|
val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 13, 10, 0)
|
||||||
|
|
||||||
|
val home = service.getHome(creatorId = 1L, viewer = viewer, now = now)
|
||||||
|
|
||||||
|
assertEquals(1L, home.creator.creatorId)
|
||||||
|
assertEquals("https://cdn.test/profile/creator.png", home.creator.profileImageUrl)
|
||||||
|
assertEquals("https://cdn.test/live.png", home.currentLive?.coverImageUrl)
|
||||||
|
assertEquals("https://cdn.test/audio/latest.png", home.latestAudioContent?.imageUrl)
|
||||||
|
assertEquals(listOf(203L, 202L), home.audioContents.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(402L, 401L, 404L), home.schedules.map { it.targetId })
|
||||||
|
assertFalse(home.schedules.any { it.isAdult })
|
||||||
|
assertEquals("https://cdn.test/profile/fan.png", home.channelDonations.first().profileImageUrl)
|
||||||
|
assertEquals("https://cdn.test/community.png", home.notices.first().imageUrl)
|
||||||
|
assertEquals("https://cdn.test/series.png", home.series.first().coverImageUrl)
|
||||||
|
assertEquals("introduce", home.introduce)
|
||||||
|
assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender)
|
||||||
|
assertEquals(Gender.MALE, port.schedulesEffectiveViewerGender)
|
||||||
|
assertEquals(10L, port.currentLiveViewerId)
|
||||||
|
assertEquals(10L, port.schedulesViewerId)
|
||||||
|
assertFalse(port.currentLiveIsViewerCreator == true)
|
||||||
|
assertFalse(port.schedulesIsViewerCreator == true)
|
||||||
|
assertEquals(false, port.currentLiveCanViewAdultContent)
|
||||||
|
assertEquals(false, port.schedulesCanViewAdultContent)
|
||||||
|
assertEquals(201L, port.audioContentsLatestAudioContentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자가 크리에이터 본인이면 라이브 조회 정책 컨텍스트에 본인 여부를 전달한다")
|
||||||
|
fun shouldPassViewerCreatorFlagToLivePolicyQueries() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort()
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null)
|
||||||
|
|
||||||
|
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||||
|
|
||||||
|
assertTrue(port.currentLiveIsViewerCreator == true)
|
||||||
|
assertTrue(port.schedulesIsViewerCreator == true)
|
||||||
|
assertEquals(Gender.FEMALE, port.currentLiveEffectiveViewerGender)
|
||||||
|
assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort().apply {
|
||||||
|
creator = null
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getHome(creatorId = 999L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.user_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort().apply {
|
||||||
|
creator = creator?.copy(role = MemberRole.USER)
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 관계가 있으면 대상 회원이 크리에이터가 아니어도 접근 차단 예외를 먼저 던진다")
|
||||||
|
fun shouldThrowBlockedAccessBeforeCreatorNotFoundWhenViewerAndTargetAreBlocked() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort().apply {
|
||||||
|
creator = creator?.copy(role = MemberRole.USER)
|
||||||
|
blocked = true
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(exception.messageKey)
|
||||||
|
assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자와 크리에이터 사이에 차단 관계가 있으면 기존 채널 접근 차단 메시지를 던진다")
|
||||||
|
fun shouldThrowBlockedAccessWhenViewerAndCreatorAreBlocked() {
|
||||||
|
val port = FakeCreatorChannelHomeQueryPort().apply {
|
||||||
|
blocked = true
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(exception.messageKey)
|
||||||
|
assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("크리에이터 채널 홈 모델은 홈 탭 전체 섹션을 담고 응답 DTO로 변환된다")
|
@DisplayName("크리에이터 채널 홈 모델은 홈 탭 전체 섹션을 담고 응답 DTO로 변환된다")
|
||||||
fun shouldConvertCreatorChannelHomeToResponse() {
|
fun shouldConvertCreatorChannelHomeToResponse() {
|
||||||
@@ -233,4 +375,277 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createService(
|
||||||
|
port: FakeCreatorChannelHomeQueryPort,
|
||||||
|
canViewAdultContent: Boolean = true
|
||||||
|
): CreatorChannelHomeQueryService {
|
||||||
|
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 messageSource = SodaMessageSource()
|
||||||
|
val langContext = LangContext()
|
||||||
|
langContext.setLang(Lang.KO)
|
||||||
|
return CreatorChannelHomeQueryService(
|
||||||
|
queryPort = port,
|
||||||
|
queryPolicy = CreatorChannelHomeQueryPolicy(),
|
||||||
|
memberContentPreferenceService = preferenceService,
|
||||||
|
messageSource = messageSource,
|
||||||
|
langContext = langContext,
|
||||||
|
cloudFrontHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(
|
||||||
|
id: Long,
|
||||||
|
gender: Gender = Gender.NONE,
|
||||||
|
authGender: Int? = null
|
||||||
|
): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "member$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "member$id",
|
||||||
|
provider = MemberProvider.EMAIL,
|
||||||
|
gender = gender
|
||||||
|
)
|
||||||
|
member.id = id
|
||||||
|
authGender?.let {
|
||||||
|
Auth(
|
||||||
|
name = "name",
|
||||||
|
birth = "19900101",
|
||||||
|
uniqueCi = "ci$id",
|
||||||
|
di = "di$id",
|
||||||
|
gender = it
|
||||||
|
).member = member
|
||||||
|
}
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
||||||
|
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
characterId = 11L,
|
||||||
|
nickname = "creator",
|
||||||
|
profileImagePath = "profile/creator.png",
|
||||||
|
introduce = "introduce",
|
||||||
|
followerCount = 100,
|
||||||
|
isAiChatAvailable = true,
|
||||||
|
isDmAvailable = true,
|
||||||
|
isFollow = true,
|
||||||
|
isNotify = false
|
||||||
|
)
|
||||||
|
var blocked = false
|
||||||
|
var currentLiveViewerId: Long? = null
|
||||||
|
var currentLiveIsViewerCreator: Boolean? = null
|
||||||
|
var currentLiveEffectiveViewerGender: Gender? = null
|
||||||
|
var currentLiveCanViewAdultContent: Boolean? = null
|
||||||
|
var schedulesViewerId: Long? = null
|
||||||
|
var schedulesIsViewerCreator: Boolean? = null
|
||||||
|
var schedulesEffectiveViewerGender: Gender? = null
|
||||||
|
var schedulesCanViewAdultContent: Boolean? = null
|
||||||
|
var audioContentsLatestAudioContentId: Long? = null
|
||||||
|
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
|
||||||
|
|
||||||
|
override fun findCurrentLive(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long?,
|
||||||
|
isViewerCreator: Boolean,
|
||||||
|
effectiveViewerGender: Gender?
|
||||||
|
): CreatorChannelLiveRecord? {
|
||||||
|
currentLiveViewerId = viewerId
|
||||||
|
currentLiveIsViewerCreator = isViewerCreator
|
||||||
|
currentLiveEffectiveViewerGender = effectiveViewerGender
|
||||||
|
currentLiveCanViewAdultContent = canViewAdultContent
|
||||||
|
return CreatorChannelLiveRecord(
|
||||||
|
liveId = 101L,
|
||||||
|
title = "live",
|
||||||
|
coverImagePath = "live.png",
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 13, 9, 0),
|
||||||
|
price = 10,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findLatestAudioContent(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png")
|
||||||
|
|
||||||
|
override fun findChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long?,
|
||||||
|
now: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelDonationRecord> = listOf(
|
||||||
|
CreatorChannelDonationRecord(
|
||||||
|
nickname = "fan",
|
||||||
|
profileImagePath = "profile/fan.png",
|
||||||
|
can = 30,
|
||||||
|
message = "thanks",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun findCommunityPosts(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long?,
|
||||||
|
isFixed: Boolean,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityPostRecord> = listOf(
|
||||||
|
CreatorChannelCommunityPostRecord(
|
||||||
|
postId = if (isFixed) 301L else 302L,
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfilePath = "profile/creator.png",
|
||||||
|
imagePath = "community.png",
|
||||||
|
audioPath = "community.mp3",
|
||||||
|
content = if (isFixed) "notice" else "community",
|
||||||
|
price = 0,
|
||||||
|
date = LocalDateTime.of(2026, 6, 13, 7, 0),
|
||||||
|
existOrdered = false,
|
||||||
|
likeCount = 3,
|
||||||
|
commentCount = 4
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun findSchedules(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
viewerId: Long?,
|
||||||
|
isViewerCreator: Boolean,
|
||||||
|
effectiveViewerGender: Gender?,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelScheduleRecord> {
|
||||||
|
schedulesViewerId = viewerId
|
||||||
|
schedulesIsViewerCreator = isViewerCreator
|
||||||
|
schedulesEffectiveViewerGender = effectiveViewerGender
|
||||||
|
schedulesCanViewAdultContent = canViewAdultContent
|
||||||
|
return listOf(
|
||||||
|
scheduleRecord(401L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.AUDIO, false),
|
||||||
|
scheduleRecord(402L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.LIVE, false),
|
||||||
|
scheduleRecord(403L, LocalDateTime.of(2026, 6, 13, 13, 0), CreatorActivityType.LIVE, true),
|
||||||
|
scheduleRecord(404L, LocalDateTime.of(2026, 6, 13, 14, 0), CreatorActivityType.AUDIO, false),
|
||||||
|
scheduleRecord(405L, LocalDateTime.of(2026, 6, 13, 15, 0), CreatorActivityType.LIVE, false),
|
||||||
|
scheduleRecord(406L, LocalDateTime.of(2026, 6, 13, 9, 0), CreatorActivityType.LIVE, false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findAudioContents(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
latestAudioContentId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
|
audioContentsLatestAudioContentId = latestAudioContentId
|
||||||
|
return listOf(
|
||||||
|
audioContentRecord(201L, "audio/latest.png"),
|
||||||
|
audioContentRecord(203L, "audio/203.png"),
|
||||||
|
audioContentRecord(202L, "audio/202.png")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findSeries(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long?,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelSeriesRecord> = listOf(
|
||||||
|
CreatorChannelSeriesRecord(
|
||||||
|
seriesId = 501L,
|
||||||
|
title = "series",
|
||||||
|
coverImagePath = "series.png",
|
||||||
|
numberOfContent = 2,
|
||||||
|
isNew = true,
|
||||||
|
isOriginal = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
|
||||||
|
return CreatorChannelFanTalkSummaryRecord(
|
||||||
|
totalCount = 1,
|
||||||
|
latestFanTalk = CreatorChannelFanTalkRecord(
|
||||||
|
fanTalkId = 601L,
|
||||||
|
memberId = 10L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImagePath = "profile/fan.png",
|
||||||
|
content = "hello",
|
||||||
|
languageCode = "ko",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 13, 6, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord {
|
||||||
|
return CreatorChannelActivityRecord(
|
||||||
|
debutDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||||
|
dDay = "D+12",
|
||||||
|
liveCount = 1,
|
||||||
|
liveDurationHours = 2,
|
||||||
|
liveContributorCount = 3,
|
||||||
|
audioContentCount = 4,
|
||||||
|
seriesCount = 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findSns(creatorId: Long): CreatorChannelSnsRecord {
|
||||||
|
return CreatorChannelSnsRecord(
|
||||||
|
instagramUrl = "instagram",
|
||||||
|
fancimmUrl = "fancimm",
|
||||||
|
xUrl = "x",
|
||||||
|
youtubeUrl = "youtube",
|
||||||
|
kakaoOpenChatUrl = "kakao"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioContentRecord(audioContentId: Long, imagePath: String): CreatorChannelAudioContentRecord {
|
||||||
|
return CreatorChannelAudioContentRecord(
|
||||||
|
audioContentId = audioContentId,
|
||||||
|
title = "audio-$audioContentId",
|
||||||
|
duration = "00:10:00",
|
||||||
|
imagePath = imagePath,
|
||||||
|
price = 10,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isFirstContent = false,
|
||||||
|
publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60),
|
||||||
|
seriesName = null,
|
||||||
|
isOriginalSeries = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRecord(
|
||||||
|
targetId: Long,
|
||||||
|
scheduledAt: LocalDateTime,
|
||||||
|
type: CreatorActivityType,
|
||||||
|
isAdult: Boolean
|
||||||
|
): CreatorChannelScheduleRecord {
|
||||||
|
return CreatorChannelScheduleRecord(
|
||||||
|
scheduledAt = scheduledAt,
|
||||||
|
title = "schedule-$targetId",
|
||||||
|
type = type,
|
||||||
|
targetId = targetId,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user