test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
4 changed files with 196 additions and 3 deletions
Showing only changes of commit 059cef8a35 - Show all commits
@@ -6,10 +6,12 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
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 kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -54,7 +56,8 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
private val chatCharacterService: ChatCharacterService,
private val objectMapper: ObjectMapper,
private val entityManager: EntityManager,
private val transactionManager: PlatformTransactionManager
private val transactionManager: PlatformTransactionManager,
private val creatorCommunityService: CreatorCommunityService
) {
@MockBean
private lateinit var amazonS3Client: AmazonS3Client
@@ -62,9 +65,15 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
@MockBean
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@MockBean
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
@SpyBean
private lateinit var creatorCommunityRepository: CreatorCommunityRepository
@SpyBean
private lateinit var aiCharacterAdminCommunityPostRepository: AiCharacterAdminCommunityPostRepository
@Test
@DisplayName("순차 고정 요청에서 세 번째는 허용되고 네 번째는 최대 고정 수로 거부된다")
fun shouldAllowThirdFixedPostAndRejectFourthWhenRequestsAreSerialized() {
@@ -167,6 +176,133 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
assertEquals(3L, transactionTemplate.execute { fixedPostCount(fixture.ownerId) })
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("병렬 본문 수정은 현재 행 잠금으로 개정을 순서대로 증가시킨다")
fun shouldSerializeContentRevisionsWhenUpdatesRace() {
val transactionTemplate = TransactionTemplate(transactionManager)
val fixture = transactionTemplate.execute {
val character = createCharacter("community-content-race")
val post = savePost(character.creatorMember!!, "before").apply {
languageCode = "ko"
contentRevision = 0
}
entityManager.flush()
ContentRaceFixture(character.id!!, post.id!!)
} ?: throw IllegalStateException("fixture creation failed")
val executor = Executors.newFixedThreadPool(2)
val updatesReady = CountDownLatch(2)
val startUpdates = CountDownLatch(1)
try {
val first = executor.submit<Int> {
updatesReady.countDown()
startUpdates.await(5, TimeUnit.SECONDS)
mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "first"))
.andReturn()
.response
.status
}
val second = executor.submit<Int> {
updatesReady.countDown()
startUpdates.await(5, TimeUnit.SECONDS)
mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "second"))
.andReturn()
.response
.status
}
assertTrue(updatesReady.await(5, TimeUnit.SECONDS))
startUpdates.countDown()
assertEquals(listOf(200, 200), listOf(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)))
} finally {
startUpdates.countDown()
executor.shutdown()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
}
val updated = transactionTemplate.execute {
entityManager.clear()
entityManager.find(CreatorCommunity::class.java, fixture.postId)
}!!
assertEquals(2, updated.contentRevision)
assertTrue(updated.content == "first" || updated.content == "second")
Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, null)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("고정 상태의 stale read는 최신 본문 수정의 개정과 언어를 덮어쓰지 않는다")
fun shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit() {
val transactionTemplate = TransactionTemplate(transactionManager)
val fixture = transactionTemplate.execute {
val character = createCharacter("community-fixed-content-race")
val post = savePost(character.creatorMember!!, "before").apply {
languageCode = "ko"
contentRevision = 0
}
entityManager.flush()
ContentRaceFixture(character.id!!, post.id!!)
} ?: throw IllegalStateException("fixture creation failed")
val staleAdminRead = CountDownLatch(1)
val releaseFixedUpdate = CountDownLatch(1)
val contentUpdatePreLock = CountDownLatch(1)
val fixedReadPaused = AtomicBoolean(false)
Mockito.doAnswer { invocation ->
val post = invocation.callRealMethod() as CreatorCommunity?
if (post?.id == fixture.postId && fixedReadPaused.compareAndSet(false, true)) {
staleAdminRead.countDown()
releaseFixedUpdate.await(5, TimeUnit.SECONDS)
}
post
}.`when`(aiCharacterAdminCommunityPostRepository)
.findActiveByIdAndCreatorMemberId(Mockito.anyLong(), Mockito.anyLong())
val executor = Executors.newFixedThreadPool(2)
try {
val fixedAndContent = executor.submit<Int> {
mockMvc.perform(fixedAndContentRequest(fixture.characterId, fixture.postId, "after fixed"))
.andReturn()
.response
.status
}
assertTrue(staleAdminRead.await(5, TimeUnit.SECONDS))
val concurrentContent = executor.submit<Int> {
contentUpdatePreLock.countDown()
creatorCommunityService.modifyCommunityPost(
postImage = null,
requestString = objectMapper.writeValueAsString(
mapOf("creatorCommunityId" to fixture.postId, "content" to "after concurrent")
),
member = transactionTemplate.execute {
entityManager.find(CreatorCommunity::class.java, fixture.postId).member!!
}!!
)
200
}
assertTrue(contentUpdatePreLock.await(5, TimeUnit.SECONDS))
assertEquals(200, concurrentContent.get(5, TimeUnit.SECONDS))
releaseFixedUpdate.countDown()
assertEquals(200, fixedAndContent.get(5, TimeUnit.SECONDS))
} finally {
releaseFixedUpdate.countDown()
executor.shutdown()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
}
val updated = transactionTemplate.execute {
entityManager.clear()
entityManager.find(CreatorCommunity::class.java, fixture.postId)
}!!
assertEquals("after fixed", updated.content)
assertEquals(2, updated.contentRevision)
assertEquals(null, updated.languageCode)
assertTrue(updated.isFixed)
assertNotNull(updated.fixedAt)
}
@Test
@DisplayName("invalid target와 cross-owner 고정 요청은 DB, S3, event 변경 없이 거부된다")
fun shouldRejectInvalidTargetAndCrossOwnerFixedRequestsWithoutSideEffects() {
@@ -213,6 +349,30 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
postImage?.let(::file)
}.with(adminAuthentication())
private fun updateContentRequest(characterId: Long, postId: Long, content: String) =
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId")
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
objectMapper.writeValueAsBytes(mapOf("content" to content))
)
)
.with(adminAuthentication())
private fun fixedAndContentRequest(characterId: Long, postId: Long, content: String) =
multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId")
.file(
MockMultipartFile(
"request",
"request.json",
MediaType.APPLICATION_JSON_VALUE,
objectMapper.writeValueAsBytes(mapOf("isFixed" to true, "content" to content))
)
)
.with(adminAuthentication())
private fun pngFile() = MockMultipartFile(
"postImage",
"post.png",
@@ -283,4 +443,9 @@ class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor(
val firstPostId: Long,
val secondPostId: Long
)
private data class ContentRaceFixture(
val characterId: Long,
val postId: Long
)
}
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -34,6 +35,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multi
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.net.URL
import javax.persistence.EntityManager
@@ -51,6 +53,9 @@ class AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
@MockBean
private lateinit var amazonS3Client: AmazonS3Client
@MockBean
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
@Test
@DisplayName("정상 무료 게시글 생성은 target 소유자를 작성자로 사용하고 data null을 반환한다")
fun shouldCreateFreePostForTargetOwnerAndReturnNullData() {
@@ -73,6 +78,21 @@ class AiCharacterAdminCommunityPostCreateTest @Autowired constructor(
assertTrue(post.member!!.id != otherCharacter.creatorMember!!.id)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("관리자 생성은 게시글 커밋 후 공용 번역 요청을 한 번 보낸다")
fun shouldRequestTranslationsAfterCommittedAdminCreation() {
val character = createCharacter("community-create-translation")
val content = "translation request after committed admin creation"
mockMvc.perform(createRequest(character.id!!, content = content))
.andExpect(status().isOk)
val post = findPost(content)
assertNotNull(post)
Mockito.verify(creatorCommunityTranslationService).requestTranslations(post!!.id!!, null)
}
@Test
@DisplayName("생성 price 생략은 기본값 0으로 게시글을 생성한다")
fun shouldCreatePostWithDefaultPriceWhenPriceIsOmitted() {
@@ -62,6 +62,8 @@ class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
val post = savePost(character.creatorMember!!, "before", isCommentAvailable = true, isAdult = false).apply {
isFixed = true
fixedAt = LocalDateTime.now()
languageCode = "ko"
contentRevision = 7
}
entityManager.flush()
@@ -86,6 +88,8 @@ class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor(
assertTrue(updated.isAdult)
assertTrue(updated.isFixed)
assertNotNull(updated.fixedAt)
assertNull(updated.languageCode)
assertEquals(8, updated.contentRevision)
Mockito.verifyNoInteractions(amazonS3Client)
}
@@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -37,6 +38,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.web.multipart.MultipartFile
import java.io.InputStream
import java.time.LocalDateTime
import javax.persistence.EntityManager
class LegacyCommunityPostCharacterizationTest {
private lateinit var repository: CreatorCommunityRepository
@@ -65,6 +67,8 @@ class LegacyCommunityPostCharacterizationTest {
messageSource = SodaMessageSource(),
langContext = LangContext(),
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java),
entityManager = Mockito.mock(EntityManager::class.java),
imageBucket = "image-bucket",
contentBucket = "content-bucket",
imageHost = "https://cdn.test"
@@ -176,7 +180,7 @@ class LegacyCommunityPostCharacterizationTest {
post.member = creator
post.isFixed = true
post.fixedAt = LocalDateTime.of(2026, 7, 28, 10, 0)
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = true), creator)
@@ -193,7 +197,7 @@ class LegacyCommunityPostCharacterizationTest {
val otherPost = CreatorCommunity("other fixed post", 0, true, false)
otherPost.id = 1004L
otherPost.member = creator
Mockito.`when`(repository.findByIdAndMemberId(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(otherPost.id!!, creator.id!!)).thenReturn(otherPost)
val exception = assertThrows(SodaException::class.java) {
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(otherPost.id!!, isFixed = true), creator)
}