feat(home): 크리에이터 채널 홈 정책을 보강한다
This commit is contained in:
@@ -36,6 +36,7 @@ import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
@@ -57,6 +58,8 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private const val MAX_PIN_CONTENT_COUNT = 9
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class AudioContentService(
|
||||
@@ -72,6 +75,7 @@ class AudioContentService(
|
||||
private val commentRepository: AudioContentCommentRepository,
|
||||
private val audioContentLikeRepository: AudioContentLikeRepository,
|
||||
private val pinContentRepository: PinContentRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
@@ -795,7 +799,7 @@ class AudioContentService(
|
||||
|
||||
val pinContentListCount = pinContentRepository.getPinContentList(memberId = member.id!!, active = true).size
|
||||
val isAvailablePin = if (member.id!! == audioContent.member!!.id!!) {
|
||||
pinContentListCount < 3
|
||||
pinContentListCount < MAX_PIN_CONTENT_COUNT
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -1227,7 +1231,9 @@ class AudioContentService(
|
||||
|
||||
@Transactional
|
||||
fun pinToTheTop(contentId: Long, member: Member) {
|
||||
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!)
|
||||
val creator = memberRepository.findByIdForUpdate(member.id!!)
|
||||
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = creator.id!!)
|
||||
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||
|
||||
if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) {
|
||||
@@ -1236,21 +1242,24 @@ class AudioContentService(
|
||||
|
||||
var pinContent = pinContentRepository.findByContentIdAndMemberId(
|
||||
contentId = contentId,
|
||||
memberId = member.id!!
|
||||
memberId = creator.id!!
|
||||
)
|
||||
val activePinContentList = pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)
|
||||
|
||||
if (pinContent != null) {
|
||||
if (!pinContent.isActive && activePinContentList.size >= MAX_PIN_CONTENT_COUNT) {
|
||||
activePinContentList[0].isActive = false
|
||||
}
|
||||
pinContent.isActive = true
|
||||
} else {
|
||||
val pinContentList = pinContentRepository.getPinContentList(memberId = member.id!!)
|
||||
pinContent = if (pinContentList.size >= 3) {
|
||||
pinContentList[0]
|
||||
pinContent = if (activePinContentList.size >= MAX_PIN_CONTENT_COUNT) {
|
||||
activePinContentList[0]
|
||||
} else {
|
||||
PinContent()
|
||||
}
|
||||
|
||||
pinContent.isActive = true
|
||||
pinContent.member = member
|
||||
pinContent.member = creator
|
||||
pinContent.content = audioContent
|
||||
pinContentRepository.save(pinContent)
|
||||
}
|
||||
@@ -1258,9 +1267,11 @@ class AudioContentService(
|
||||
|
||||
@Transactional
|
||||
fun unpinAtTheTop(contentId: Long, member: Member) {
|
||||
val creator = memberRepository.findByIdForUpdate(member.id!!)
|
||||
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||
val pinContent = pinContentRepository.findByContentIdAndMemberId(
|
||||
contentId = contentId,
|
||||
memberId = member.id!!
|
||||
memberId = creator.id!!
|
||||
) ?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||
|
||||
pinContent.isActive = false
|
||||
|
||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChann
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
@@ -15,7 +16,7 @@ class CreatorChannelHomeFacade(
|
||||
fun getHome(
|
||||
creatorId: Long,
|
||||
viewer: Member,
|
||||
now: LocalDateTime = LocalDateTime.now()
|
||||
now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
): CreatorChannelHomeResponse {
|
||||
return CreatorChannelHomeResponse.from(
|
||||
creatorChannelHomeQueryService.getHome(
|
||||
|
||||
@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
||||
@@ -269,7 +270,7 @@ class DefaultCreatorChannelHomeQueryRepository(
|
||||
viewerId: Long?,
|
||||
limit: Int
|
||||
): List<CreatorChannelAudioContentRecord> {
|
||||
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
|
||||
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
|
||||
val contentIds = rows.map { itAudioId(it) }
|
||||
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
|
||||
val seriesByContentId = audioSeriesByContentIds(contentIds)
|
||||
@@ -435,21 +436,33 @@ class DefaultCreatorChannelHomeQueryRepository(
|
||||
now: LocalDateTime,
|
||||
excludedContentId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
limit: Int
|
||||
) = queryFactory
|
||||
.select(
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
audioContent.duration,
|
||||
audioContent.coverImage,
|
||||
audioContent.price,
|
||||
audioContent.isAdult,
|
||||
audioContent.isPointAvailable,
|
||||
audioContent.releaseDate,
|
||||
audioContent.createdAt
|
||||
)
|
||||
.from(audioContent)
|
||||
.where(
|
||||
limit: Int,
|
||||
pinFirst: Boolean = false
|
||||
): List<com.querydsl.core.Tuple> {
|
||||
val query = queryFactory
|
||||
.select(
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
audioContent.duration,
|
||||
audioContent.coverImage,
|
||||
audioContent.price,
|
||||
audioContent.isAdult,
|
||||
audioContent.isPointAvailable,
|
||||
audioContent.releaseDate,
|
||||
audioContent.createdAt
|
||||
)
|
||||
.from(audioContent)
|
||||
|
||||
if (pinFirst) {
|
||||
query.leftJoin(pinContent)
|
||||
.on(
|
||||
pinContent.member.id.eq(creatorId),
|
||||
pinContent.content.id.eq(audioContent.id),
|
||||
pinContent.isActive.isTrue
|
||||
)
|
||||
}
|
||||
|
||||
query.where(
|
||||
audioContent.member.id.eq(creatorId),
|
||||
audioContent.member.isActive.isTrue,
|
||||
audioContent.isActive.isTrue,
|
||||
@@ -459,9 +472,22 @@ class DefaultCreatorChannelHomeQueryRepository(
|
||||
excludedContentId?.let { audioContent.id.ne(it) },
|
||||
adultAudioCondition(canViewAdultContent)
|
||||
)
|
||||
.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
|
||||
if (pinFirst) {
|
||||
query.orderBy(
|
||||
pinContent.isActive.desc(),
|
||||
pinContent.updatedAt.desc(),
|
||||
audioContent.releaseDate.desc(),
|
||||
audioContent.id.desc()
|
||||
)
|
||||
} else {
|
||||
query.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
|
||||
}
|
||||
|
||||
return query
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun itAudioId(row: com.querydsl.core.Tuple): Long = row.get(audioContent.id)!!
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.home.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
@@ -36,6 +37,7 @@ import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
@@ -52,7 +54,7 @@ class CreatorChannelHomeQueryService(
|
||||
fun getHome(
|
||||
creatorId: Long,
|
||||
viewer: Member,
|
||||
now: LocalDateTime = LocalDateTime.now()
|
||||
now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
): CreatorChannelHome {
|
||||
val viewerId = viewer.id!!
|
||||
val creator = queryPort.findCreator(creatorId, viewerId)
|
||||
@@ -68,8 +70,8 @@ class CreatorChannelHomeQueryService(
|
||||
validateCreatorRole(creator)
|
||||
|
||||
val preference = memberContentPreferenceService.getStoredPreference(viewer)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
|
||||
val isViewerCreator = viewerId == creatorId
|
||||
val canViewAdultContent = preference.isAdult
|
||||
val isViewerCreator = viewer.role == MemberRole.CREATOR
|
||||
val effectiveViewerGender = viewer.effectiveGender()
|
||||
val latestAudioContent = queryPort
|
||||
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
|
||||
@@ -187,7 +189,7 @@ class CreatorChannelHomeQueryService(
|
||||
)
|
||||
|
||||
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
|
||||
nickname = nickname,
|
||||
nickname = nickname.removeDeletedNicknamePrefix(),
|
||||
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||
can = can,
|
||||
message = message,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@Transactional
|
||||
class AudioContentPinConcurrencyTest @Autowired constructor(
|
||||
private val service: AudioContentService,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("동시 상단 고정 요청에서도 활성 고정은 9개를 넘지 않는다")
|
||||
fun shouldKeepNineActivePinsWhenPinRequestsRace() {
|
||||
val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
val fixture = transactionTemplate.execute {
|
||||
val creator = saveMember("pin-race-creator", MemberRole.CREATOR)
|
||||
val existingContents = (1..8).map { saveAudioContent(creator, "existing-$it") }
|
||||
existingContents.forEach { savePinContent(creator, it) }
|
||||
val first = saveAudioContent(creator, "first-race")
|
||||
val second = saveAudioContent(creator, "second-race")
|
||||
entityManager.flush()
|
||||
PinRaceFixture(creator.id!!, first.id!!, second.id!!)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
|
||||
val start = CountDownLatch(1)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val first = executor.submit {
|
||||
start.await()
|
||||
service.pinToTheTop(fixture.firstContentId, memberRef(fixture.creatorId))
|
||||
}
|
||||
val second = executor.submit {
|
||||
start.await()
|
||||
service.pinToTheTop(fixture.secondContentId, memberRef(fixture.creatorId))
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
first.get(5, TimeUnit.SECONDS)
|
||||
second.get(5, TimeUnit.SECONDS)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
val activeContentIds = transactionTemplate.execute { activePinnedContentIds(fixture.creatorId) }.orEmpty()
|
||||
assertEquals(9, activeContentIds.size)
|
||||
assertEquals(activeContentIds.size, activeContentIds.distinct().size)
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = role
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveAudioContent(creator: Member, title: String): AudioContent {
|
||||
val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
val content = AudioContent(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = LocalDateTime.now().minusDays(1),
|
||||
isAdult = false
|
||||
)
|
||||
content.member = creator
|
||||
content.theme = theme
|
||||
content.isActive = true
|
||||
content.coverImage = "$title.png"
|
||||
content.duration = "00:10:00"
|
||||
entityManager.persist(content)
|
||||
return content
|
||||
}
|
||||
|
||||
private fun savePinContent(creator: Member, content: AudioContent): PinContent {
|
||||
val pinContent = PinContent(isActive = true)
|
||||
pinContent.member = creator
|
||||
pinContent.content = content
|
||||
entityManager.persist(pinContent)
|
||||
return pinContent
|
||||
}
|
||||
|
||||
private fun memberRef(memberId: Long): Member {
|
||||
val member = Member(email = "creator-$memberId@test.com", password = "password", nickname = "creator-$memberId")
|
||||
member.id = memberId
|
||||
return member
|
||||
}
|
||||
|
||||
private fun activePinnedContentIds(creatorId: Long): List<Long> {
|
||||
return entityManager.createQuery(
|
||||
"select p.content.id from PinContent p where p.member.id = :creatorId and p.isActive = true",
|
||||
java.lang.Long::class.java
|
||||
).setParameter("creatorId", creatorId).resultList.map { it.toLong() }
|
||||
}
|
||||
|
||||
private data class PinRaceFixture(
|
||||
val creatorId: Long,
|
||||
val firstContentId: Long,
|
||||
val secondContentId: Long
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
|
||||
import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository
|
||||
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
@@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
||||
@@ -52,6 +54,7 @@ class AudioContentServiceTest {
|
||||
private lateinit var commentRepository: AudioContentCommentRepository
|
||||
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
|
||||
private lateinit var pinContentRepository: PinContentRepository
|
||||
private lateinit var memberRepository: MemberRepository
|
||||
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
private lateinit var contentTranslationRepository: ContentTranslationRepository
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
@@ -76,6 +79,7 @@ class AudioContentServiceTest {
|
||||
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
|
||||
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
|
||||
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
|
||||
memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
|
||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
@@ -97,6 +101,7 @@ class AudioContentServiceTest {
|
||||
commentRepository = commentRepository,
|
||||
audioContentLikeRepository = audioContentLikeRepository,
|
||||
pinContentRepository = pinContentRepository,
|
||||
memberRepository = memberRepository,
|
||||
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
|
||||
contentTranslationRepository = contentTranslationRepository,
|
||||
s3Uploader = s3Uploader,
|
||||
@@ -450,6 +455,123 @@ class AudioContentServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오디오 상세의 상단 고정 가능 여부는 활성 고정 9개 상한을 따른다")
|
||||
fun shouldExposeAvailablePinByNineActivePinLimit() {
|
||||
val creator = createMember(id = 2400L, nickname = "pin-limit-creator")
|
||||
val audioContent = createAudioContent(creator = creator)
|
||||
stubSuccessfulDetailDependencies(creator, creator, audioContent)
|
||||
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
|
||||
.thenReturn(createPinContents(8))
|
||||
.thenReturn(createPinContents(9))
|
||||
|
||||
val availableResponse = service.getDetail(
|
||||
id = audioContent.id!!,
|
||||
member = creator,
|
||||
isAdultContentVisible = false,
|
||||
timezone = "Asia/Seoul"
|
||||
)
|
||||
val unavailableResponse = service.getDetail(
|
||||
id = audioContent.id!!,
|
||||
member = creator,
|
||||
isAdultContentVisible = false,
|
||||
timezone = "Asia/Seoul"
|
||||
)
|
||||
|
||||
assertTrue(availableResponse.isAvailablePin)
|
||||
assertEquals(false, unavailableResponse.isAvailablePin)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("10번째 상단 고정은 가장 오래된 활성 고정을 새 콘텐츠로 교체한다")
|
||||
fun shouldReuseOldestActivePinWhenPinningTenthContent() {
|
||||
val creator = createMember(id = 2410L, nickname = "pin-reuse-creator")
|
||||
val audioContent = createAudioContent(creator = creator)
|
||||
val oldestPinContent = createPinContents(1).single()
|
||||
Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!))
|
||||
.thenReturn(audioContent)
|
||||
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
|
||||
Mockito.`when`(
|
||||
pinContentRepository.findByContentIdAndMemberId(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = creator.id!!
|
||||
)
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
|
||||
.thenReturn(listOf(oldestPinContent) + createPinContents(8))
|
||||
|
||||
service.pinToTheTop(contentId = audioContent.id!!, member = creator)
|
||||
|
||||
Mockito.inOrder(memberRepository, repository, pinContentRepository).apply {
|
||||
verify(memberRepository).findByIdForUpdate(creator.id!!)
|
||||
verify(repository).findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)
|
||||
verify(pinContentRepository).findByContentIdAndMemberId(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = creator.id!!
|
||||
)
|
||||
verify(pinContentRepository).getPinContentList(memberId = creator.id!!, active = true)
|
||||
}
|
||||
assertTrue(oldestPinContent.isActive)
|
||||
assertEquals(creator, oldestPinContent.member)
|
||||
assertEquals(audioContent, oldestPinContent.content)
|
||||
Mockito.verify(pinContentRepository).save(oldestPinContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상단 고정 해제는 고정 행 조회 전에 크리에이터를 잠근다")
|
||||
fun shouldLockCreatorBeforeFindingPinWhenUnpinningContent() {
|
||||
val creator = createMember(id = 2415L, nickname = "pin-unpin-lock-creator")
|
||||
val audioContent = createAudioContent(creator = creator)
|
||||
val pinContent = PinContent(isActive = true)
|
||||
pinContent.member = creator
|
||||
pinContent.content = audioContent
|
||||
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
|
||||
Mockito.`when`(
|
||||
pinContentRepository.findByContentIdAndMemberId(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = creator.id!!
|
||||
)
|
||||
).thenReturn(pinContent)
|
||||
|
||||
service.unpinAtTheTop(contentId = audioContent.id!!, member = creator)
|
||||
|
||||
Mockito.inOrder(memberRepository, pinContentRepository).apply {
|
||||
verify(memberRepository).findByIdForUpdate(creator.id!!)
|
||||
verify(pinContentRepository).findByContentIdAndMemberId(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = creator.id!!
|
||||
)
|
||||
}
|
||||
assertEquals(false, pinContent.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 고정을 다시 활성화할 때도 활성 고정은 9개를 넘지 않는다")
|
||||
fun shouldKeepNineActivePinsWhenReactivatingInactivePin() {
|
||||
val creator = createMember(id = 2420L, nickname = "pin-reactivate-creator")
|
||||
val audioContent = createAudioContent(creator = creator)
|
||||
val inactivePinContent = PinContent(isActive = false)
|
||||
inactivePinContent.member = creator
|
||||
inactivePinContent.content = audioContent
|
||||
val oldestActivePinContent = createPinContents(1).single()
|
||||
Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!))
|
||||
.thenReturn(audioContent)
|
||||
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
|
||||
Mockito.`when`(
|
||||
pinContentRepository.findByContentIdAndMemberId(
|
||||
contentId = audioContent.id!!,
|
||||
memberId = creator.id!!
|
||||
)
|
||||
).thenReturn(inactivePinContent)
|
||||
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
|
||||
.thenReturn(listOf(oldestActivePinContent) + createPinContents(8))
|
||||
|
||||
service.pinToTheTop(contentId = audioContent.id!!, member = creator)
|
||||
|
||||
assertEquals(false, oldestActivePinContent.isActive)
|
||||
assertTrue(inactivePinContent.isActive)
|
||||
}
|
||||
|
||||
private fun createMember(id: Long, nickname: String): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
@@ -496,6 +618,10 @@ class AudioContentServiceTest {
|
||||
return audioContent
|
||||
}
|
||||
|
||||
private fun createPinContents(count: Int): List<PinContent> {
|
||||
return (1..count).map { PinContent() }
|
||||
}
|
||||
|
||||
private fun stubSuccessfulDetailDependencies(viewer: Member, creator: Member, audioContent: AudioContent) {
|
||||
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
|
||||
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.home
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:creator-channel-home-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class CreatorChannelHomeEndToEndTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val entityManager: EntityManager,
|
||||
private val transactionTemplate: TransactionTemplate
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 홈 API는 단일 인증 요청으로 전체 홈 섹션을 조립한다")
|
||||
fun shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest() {
|
||||
Mockito.doReturn("US").`when`(countryContext).countryCode
|
||||
val fixture = createFixture()
|
||||
|
||||
mockMvc.perform(get("/api/v2/creator-channels/${fixture.creatorId}/home").with(user(MemberAdapter(fixture.viewer))))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.creator.creatorId").value(fixture.creatorId))
|
||||
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
|
||||
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
|
||||
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
|
||||
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
|
||||
.andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId))
|
||||
.andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId))
|
||||
.andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId))
|
||||
.andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId))
|
||||
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
|
||||
.andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId))
|
||||
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId))
|
||||
.andExpect(jsonPath("$.data.introduce").value("e2e introduce"))
|
||||
.andExpect(jsonPath("$.data.activity.audioContentCount").value(3))
|
||||
.andExpect(jsonPath("$.data.sns.instagramUrl").value("e2e-instagram"))
|
||||
}
|
||||
|
||||
private fun createFixture(): Fixture {
|
||||
return transactionTemplate.execute {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val viewer = saveMember("home-e2e-viewer", MemberRole.USER)
|
||||
val creator = saveMember("home-e2e-creator", MemberRole.CREATOR)
|
||||
creator.introduce = "e2e introduce"
|
||||
creator.instagramUrl = "e2e-instagram"
|
||||
creator.fancimmUrl = "e2e-fancimm"
|
||||
creator.xUrl = "e2e-x"
|
||||
creator.youtubeUrl = "e2e-youtube"
|
||||
creator.websiteUrl = "e2e-kakao"
|
||||
val donor = saveMember("home-e2e-donor", MemberRole.USER)
|
||||
val fan = saveMember("home-e2e-fan", MemberRole.USER)
|
||||
val currentLive = saveLiveRoom(creator, now.minusHours(2), channelName = "e2e-live")
|
||||
val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null)
|
||||
val audioSchedule = saveAudioContent(creator, now.plusHours(2), title = "schedule-audio")
|
||||
val firstAudio = saveAudioContent(creator, now.minusDays(3), title = "first-audio")
|
||||
val listAudio = saveAudioContent(creator, now.minusDays(2), title = "list-audio")
|
||||
val latestAudio = saveAudioContent(creator, now.minusDays(1), title = "latest-audio")
|
||||
val series = saveSeries("home-e2e-series", creator)
|
||||
saveSeriesContent(series, listAudio)
|
||||
saveOrder(viewer, creator, latestAudio, OrderType.KEEP)
|
||||
saveDonation(creator, donor, now.minusHours(3))
|
||||
val notice = saveCommunityPost(creator, "e2e notice", isFixed = true, fixedAt = now.minusHours(4))
|
||||
val community = saveCommunityPost(creator, "e2e community", isFixed = false, fixedAt = null)
|
||||
val fanTalk = saveCheers(fan, creator, "e2e fan talk", now.minusMinutes(30))
|
||||
saveVisit(currentLive, viewer)
|
||||
entityManager.flush()
|
||||
Fixture(
|
||||
viewer = viewer,
|
||||
creatorId = creator.id!!,
|
||||
currentLiveId = currentLive.id!!,
|
||||
latestAudioId = latestAudio.id!!,
|
||||
liveScheduleId = liveSchedule.id!!,
|
||||
audioScheduleId = audioSchedule.id!!,
|
||||
firstAudioId = firstAudio.id!!,
|
||||
listAudioId = listAudio.id!!,
|
||||
seriesId = series.id!!,
|
||||
noticeId = notice.id!!,
|
||||
communityId = community.id!!,
|
||||
fanTalkId = fanTalk.id!!
|
||||
)
|
||||
} ?: throw IllegalStateException("fixture creation failed")
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
profileImage = "$nickname.png",
|
||||
role = role
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
|
||||
val liveRoom = LiveRoom(
|
||||
title = "live-$channelName",
|
||||
notice = "notice",
|
||||
beginDateTime = beginDateTime,
|
||||
numberOfPeople = 0,
|
||||
coverImage = "live.png",
|
||||
isAdult = false,
|
||||
price = 50
|
||||
)
|
||||
liveRoom.member = creator
|
||||
liveRoom.channelName = channelName
|
||||
liveRoom.isActive = true
|
||||
entityManager.persist(liveRoom)
|
||||
return liveRoom
|
||||
}
|
||||
|
||||
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, title: String): AudioContent {
|
||||
val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
val content = AudioContent(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = releaseDate,
|
||||
price = 100,
|
||||
isAdult = false
|
||||
)
|
||||
content.member = creator
|
||||
content.theme = theme
|
||||
content.isActive = true
|
||||
content.coverImage = "$title.png"
|
||||
content.duration = "00:10:00"
|
||||
entityManager.persist(content)
|
||||
return content
|
||||
}
|
||||
|
||||
private fun saveSeries(title: String, creator: Member): Series {
|
||||
val series = Series(title = title, introduction = "intro", languageCode = "ko", isOriginal = true, isActive = true)
|
||||
series.member = creator
|
||||
series.genre = saveSeriesGenre(title)
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveSeriesGenre(name: String): SeriesGenre {
|
||||
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
|
||||
entityManager.persist(genre)
|
||||
return genre
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = content
|
||||
entityManager.persist(seriesContent)
|
||||
return seriesContent
|
||||
}
|
||||
|
||||
private fun saveOrder(member: Member, creator: Member, content: AudioContent, type: OrderType): Order {
|
||||
val order = Order(type = type, isActive = true)
|
||||
order.member = member
|
||||
order.creator = creator
|
||||
order.audioContent = content
|
||||
entityManager.persist(order)
|
||||
return order
|
||||
}
|
||||
|
||||
private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage {
|
||||
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks")
|
||||
donation.creator = creator
|
||||
donation.member = donor
|
||||
entityManager.persist(donation)
|
||||
entityManager.flush()
|
||||
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
|
||||
return donation
|
||||
}
|
||||
|
||||
private fun saveCommunityPost(
|
||||
creator: Member,
|
||||
content: String,
|
||||
isFixed: Boolean,
|
||||
fixedAt: LocalDateTime?
|
||||
): CreatorCommunity {
|
||||
val post = CreatorCommunity(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
imagePath = "$content.png",
|
||||
isFixed = isFixed,
|
||||
fixedAt = fixedAt
|
||||
)
|
||||
post.member = creator
|
||||
entityManager.persist(post)
|
||||
return post
|
||||
}
|
||||
|
||||
private fun saveCheers(member: Member, creator: Member, cheers: String, createdAt: LocalDateTime): CreatorCheers {
|
||||
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = true)
|
||||
creatorCheers.member = member
|
||||
creatorCheers.creator = creator
|
||||
entityManager.persist(creatorCheers)
|
||||
entityManager.flush()
|
||||
updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt)
|
||||
return creatorCheers
|
||||
}
|
||||
|
||||
private fun saveVisit(room: LiveRoom, member: Member): LiveRoomVisit {
|
||||
val visit = LiveRoomVisit()
|
||||
visit.room = room
|
||||
visit.member = member
|
||||
entityManager.persist(visit)
|
||||
return visit
|
||||
}
|
||||
|
||||
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
|
||||
.setParameter("createdAt", createdAt)
|
||||
.setParameter("id", id)
|
||||
.executeUpdate()
|
||||
}
|
||||
|
||||
private data class Fixture(
|
||||
val viewer: Member,
|
||||
val creatorId: Long,
|
||||
val currentLiveId: Long,
|
||||
val latestAudioId: Long,
|
||||
val liveScheduleId: Long,
|
||||
val audioScheduleId: Long,
|
||||
val firstAudioId: Long,
|
||||
val listAudioId: Long,
|
||||
val seriesId: Long,
|
||||
val noticeId: Long,
|
||||
val communityId: Long,
|
||||
val fanTalkId: Long
|
||||
)
|
||||
}
|
||||
@@ -147,6 +147,33 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 홈 빈 섹션은 단건 null과 목록 빈 배열로 응답한다")
|
||||
fun shouldReturnNullSinglesAndEmptyArraysForEmptyCreatorChannelHomeSections() {
|
||||
val viewer = createMember(id = 10L)
|
||||
Mockito.doReturn(CreatorChannelHomeResponse.from(createEmptyHome())).`when`(facade).getHome(
|
||||
Mockito.eq(1L),
|
||||
Mockito.any(Member::class.java) ?: viewer,
|
||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/creator-channels/1/home")
|
||||
.with(user(MemberAdapter(viewer)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.currentLive").value(null as String?))
|
||||
.andExpect(jsonPath("$.data.latestAudioContent").value(null as String?))
|
||||
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk").value(null as String?))
|
||||
.andExpect(jsonPath("$.data.channelDonations").isEmpty)
|
||||
.andExpect(jsonPath("$.data.notices").isEmpty)
|
||||
.andExpect(jsonPath("$.data.schedules").isEmpty)
|
||||
.andExpect(jsonPath("$.data.audioContents").isEmpty)
|
||||
.andExpect(jsonPath("$.data.series").isEmpty)
|
||||
.andExpect(jsonPath("$.data.communities").isEmpty)
|
||||
}
|
||||
|
||||
private fun createMember(id: Long): Member {
|
||||
return Member(
|
||||
email = "viewer$id@test.com",
|
||||
@@ -288,4 +315,18 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createEmptyHome(): CreatorChannelHome {
|
||||
return createHome().copy(
|
||||
currentLive = null,
|
||||
latestAudioContent = null,
|
||||
channelDonations = emptyList(),
|
||||
notices = emptyList(),
|
||||
schedules = emptyList(),
|
||||
audioContents = emptyList(),
|
||||
series = emptyList(),
|
||||
communities = emptyList(),
|
||||
fanTalk = CreatorChannelFanTalkSummary(totalCount = 0, latestFanTalk = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.TimeZone
|
||||
|
||||
class CreatorChannelHomeFacadeTest {
|
||||
@Test
|
||||
@@ -77,6 +80,40 @@ class CreatorChannelHomeFacadeTest {
|
||||
assertEquals("instagram", response.sns.instagramUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 홈 facade 기본 now는 JVM timezone과 무관하게 UTC 기준으로 전달한다")
|
||||
fun shouldUseUtcNowWhenNowIsNotProvided() {
|
||||
val service = Mockito.mock(CreatorChannelHomeQueryService::class.java)
|
||||
val facade = CreatorChannelHomeFacade(service)
|
||||
val viewer = createMember(id = 10L)
|
||||
val previousTimeZone = TimeZone.getDefault()
|
||||
Mockito.doReturn(createHome()).`when`(service).getHome(
|
||||
Mockito.eq(1L),
|
||||
Mockito.eq(viewer) ?: viewer,
|
||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
)
|
||||
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
|
||||
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
|
||||
|
||||
facade.getHome(creatorId = 1L, viewer = viewer)
|
||||
|
||||
val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1)
|
||||
val nowCaptor = ArgumentCaptor.forClass(LocalDateTime::class.java)
|
||||
Mockito.verify(service).getHome(
|
||||
Mockito.eq(1L),
|
||||
Mockito.eq(viewer) ?: viewer,
|
||||
nowCaptor.capture() ?: LocalDateTime.MIN
|
||||
)
|
||||
val capturedNow = nowCaptor.value
|
||||
assertTrue(!capturedNow.isBefore(beforeUtc), "captured now should be after UTC lower bound")
|
||||
assertTrue(!capturedNow.isAfter(afterUtc), "captured now should be before UTC upper bound")
|
||||
} finally {
|
||||
TimeZone.setDefault(previousTimeZone)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMember(id: Long): Member {
|
||||
return Member(
|
||||
email = "viewer$id@test.com",
|
||||
|
||||
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
@@ -513,6 +514,66 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
||||
assertTrue(records.last().isPointAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다")
|
||||
fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() {
|
||||
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
|
||||
val creator = saveMember("home-audio-pin-creator", MemberRole.CREATOR)
|
||||
val pinnedRecent = saveAudioContent(creator, now.minusDays(5), isAdult = false)
|
||||
val pinnedOlder = saveAudioContent(creator, now.minusDays(4), isAdult = false)
|
||||
val normal = saveAudioContent(creator, now.minusDays(3), isAdult = false)
|
||||
val inactivePinned = saveAudioContent(creator, now.minusDays(2), isAdult = false)
|
||||
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
|
||||
savePinContent(creator, latest, isActive = true, updatedAt = now.minusMinutes(5))
|
||||
savePinContent(creator, pinnedRecent, isActive = true, updatedAt = now.minusMinutes(10))
|
||||
savePinContent(creator, pinnedOlder, isActive = true, updatedAt = now.minusMinutes(20))
|
||||
savePinContent(creator, inactivePinned, isActive = false, updatedAt = now.minusMinutes(1))
|
||||
flushAndClear()
|
||||
|
||||
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
|
||||
val records = repository.findAudioContents(
|
||||
creator.id!!,
|
||||
now,
|
||||
latestAudioContentId = latestRecord!!.audioContentId,
|
||||
canViewAdultContent = false,
|
||||
limit = 9
|
||||
)
|
||||
|
||||
assertEquals(latest.id, latestRecord.audioContentId)
|
||||
assertEquals(
|
||||
listOf(pinnedRecent.id, pinnedOlder.id, inactivePinned.id, normal.id),
|
||||
records.map { it.audioContentId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 오디오 목록은 활성 고정 후보가 9개를 넘어도 최대 9개만 조회한다")
|
||||
fun shouldLimitHomeAudioContentsToNineWhenActivePinnedCandidatesExceedLimit() {
|
||||
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
|
||||
val creator = saveMember("home-audio-pin-limit-creator", MemberRole.CREATOR)
|
||||
val pinnedContents = (1..10).map { index ->
|
||||
saveAudioContent(creator, now.minusDays(index.toLong() + 1), isAdult = false)
|
||||
}
|
||||
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
|
||||
pinnedContents.forEachIndexed { index, content ->
|
||||
savePinContent(creator, content, isActive = true, updatedAt = now.minusMinutes(index.toLong()))
|
||||
}
|
||||
flushAndClear()
|
||||
|
||||
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
|
||||
val records = repository.findAudioContents(
|
||||
creator.id!!,
|
||||
now,
|
||||
latestAudioContentId = latestRecord!!.audioContentId,
|
||||
canViewAdultContent = false,
|
||||
limit = 9
|
||||
)
|
||||
|
||||
assertEquals(latest.id, latestRecord.audioContentId)
|
||||
assertEquals(9, records.size)
|
||||
assertEquals(pinnedContents.take(9).map { it.id }, records.map { it.audioContentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 오디오와 오디오 목록은 조회자의 유효한 소장/대여 주문 상태를 함께 반환한다")
|
||||
fun shouldFindAudioContentOwnershipFlagsByViewerOrders() {
|
||||
@@ -1154,6 +1215,21 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
|
||||
return seriesContent
|
||||
}
|
||||
|
||||
private fun savePinContent(
|
||||
creator: Member,
|
||||
content: AudioContent,
|
||||
isActive: Boolean,
|
||||
updatedAt: LocalDateTime
|
||||
): PinContent {
|
||||
val pinContent = PinContent(isActive = isActive)
|
||||
pinContent.member = creator
|
||||
pinContent.content = content
|
||||
entityManager.persist(pinContent)
|
||||
entityManager.flush()
|
||||
updateUpdatedAt("PinContent", pinContent.id!!, updatedAt)
|
||||
return pinContent
|
||||
}
|
||||
|
||||
private fun saveDonation(
|
||||
creator: Member,
|
||||
donor: Member,
|
||||
|
||||
@@ -108,7 +108,7 @@ class CreatorChannelHomeQueryServiceTest {
|
||||
fun shouldPassViewerCreatorFlagToLivePolicyQueries() {
|
||||
val port = FakeCreatorChannelHomeQueryPort()
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null)
|
||||
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null, role = MemberRole.CREATOR)
|
||||
|
||||
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||
|
||||
@@ -118,6 +118,64 @@ class CreatorChannelHomeQueryServiceTest {
|
||||
assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("조회자가 다른 크리에이터여도 라이브 조회 정책 컨텍스트에 크리에이터 여부를 전달한다")
|
||||
fun shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator() {
|
||||
val port = FakeCreatorChannelHomeQueryPort()
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 10L, role = MemberRole.CREATOR)
|
||||
|
||||
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||
|
||||
assertTrue(port.currentLiveIsViewerCreator == true)
|
||||
assertTrue(port.schedulesIsViewerCreator == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 회원 조회자는 라이브 조회 정책 컨텍스트에 크리에이터가 아닌 것으로 전달한다")
|
||||
fun shouldPassNonCreatorFlagWhenViewerIsUser() {
|
||||
val port = FakeCreatorChannelHomeQueryPort()
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 10L, role = MemberRole.USER)
|
||||
|
||||
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||
|
||||
assertFalse(port.currentLiveIsViewerCreator == true)
|
||||
assertFalse(port.schedulesIsViewerCreator == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 조회는 저장된 콘텐츠 선호를 한 번만 읽어 성인 필터에 재사용한다")
|
||||
fun shouldReuseStoredPreferenceAdultFlagForAdultFilters() {
|
||||
val port = FakeCreatorChannelHomeQueryPort()
|
||||
val communityPort = FakeCreatorChannelCommunityQueryPort()
|
||||
val preferenceService = createPreferenceService(canViewAdultContent = false)
|
||||
val service = createService(port, communityPort, preferenceService = preferenceService)
|
||||
val viewer = createMember(id = 10L)
|
||||
|
||||
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||
|
||||
assertEquals(false, port.currentLiveCanViewAdultContent)
|
||||
assertEquals(false, port.schedulesCanViewAdultContent)
|
||||
assertEquals(listOf(false, false), communityPort.homeCanViewAdultContentValues)
|
||||
Mockito.verify(preferenceService).getStoredPreference(viewer)
|
||||
Mockito.verify(preferenceService, Mockito.never()).canViewAdultContent(viewer)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 채널 후원자는 삭제 회원 닉네임 prefix를 제거해 조립한다")
|
||||
fun shouldRemoveDeletedNicknamePrefixFromChannelDonation() {
|
||||
val port = FakeCreatorChannelHomeQueryPort().apply {
|
||||
channelDonationNickname = "deleted_donor"
|
||||
}
|
||||
val service = createService(port)
|
||||
val viewer = createMember(id = 10L)
|
||||
|
||||
val home = service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
|
||||
|
||||
assertEquals("donor", home.channelDonations.first().nickname)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
|
||||
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
|
||||
@@ -409,22 +467,9 @@ class CreatorChannelHomeQueryServiceTest {
|
||||
private fun createService(
|
||||
port: FakeCreatorChannelHomeQueryPort,
|
||||
communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(),
|
||||
canViewAdultContent: Boolean = true
|
||||
canViewAdultContent: Boolean = true,
|
||||
preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent)
|
||||
): 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
|
||||
)
|
||||
)
|
||||
Mockito.`when`(
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
val messageSource = SodaMessageSource()
|
||||
val langContext = LangContext()
|
||||
langContext.setLang(Lang.KO)
|
||||
@@ -450,17 +495,37 @@ class CreatorChannelHomeQueryServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPreferenceService(canViewAdultContent: Boolean): MemberContentPreferenceService {
|
||||
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
|
||||
)
|
||||
)
|
||||
Mockito.`when`(
|
||||
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
|
||||
).thenReturn(canViewAdultContent)
|
||||
return preferenceService
|
||||
}
|
||||
|
||||
private fun createMember(
|
||||
id: Long,
|
||||
gender: Gender = Gender.NONE,
|
||||
authGender: Int? = null
|
||||
authGender: Int? = null,
|
||||
role: MemberRole = MemberRole.USER
|
||||
): Member {
|
||||
val member = Member(
|
||||
email = "member$id@test.com",
|
||||
password = "password",
|
||||
nickname = "member$id",
|
||||
provider = MemberProvider.EMAIL,
|
||||
gender = gender
|
||||
gender = gender,
|
||||
role = role
|
||||
)
|
||||
member.id = id
|
||||
authGender?.let {
|
||||
@@ -500,6 +565,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
||||
var schedulesEffectiveViewerGender: Gender? = null
|
||||
var schedulesCanViewAdultContent: Boolean? = null
|
||||
var audioContentsLatestAudioContentId: Long? = null
|
||||
var channelDonationNickname: String = "fan"
|
||||
|
||||
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
|
||||
|
||||
@@ -541,7 +607,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
|
||||
limit: Int
|
||||
): List<CreatorChannelDonationRecord> = listOf(
|
||||
CreatorChannelDonationRecord(
|
||||
nickname = "fan",
|
||||
nickname = channelDonationNickname,
|
||||
profileImagePath = "profile/fan.png",
|
||||
can = 30,
|
||||
message = "thanks",
|
||||
|
||||
Reference in New Issue
Block a user