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 { 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 ) }