feat(home): 크리에이터 채널 홈 정책을 보강한다
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user