diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt index 77d1574c..2b820a4d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt @@ -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 { + updatesReady.countDown() + startUpdates.await(5, TimeUnit.SECONDS) + mockMvc.perform(updateContentRequest(fixture.characterId, fixture.postId, "first")) + .andReturn() + .response + .status + } + + val second = executor.submit { + 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 { + mockMvc.perform(fixedAndContentRequest(fixture.characterId, fixture.postId, "after fixed")) + .andReturn() + .response + .status + } + assertTrue(staleAdminRead.await(5, TimeUnit.SECONDS)) + + val concurrentContent = executor.submit { + 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 + ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt index cb200f9f..92b48e1e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt @@ -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() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt index 571e02a2..a64cd6e8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt @@ -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) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt index 7eaaad48..e3978075 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt @@ -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) }