From 1b19ade69e8567ce95aa0b7e0aa575fc9e3f40fc Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:12:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/LanguageDetectEvent.kt | 65 +- .../CreatorCommunityLanguageDetectTest.kt | 813 ++++++++++++++++++ 2 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 0e77b44e..669b8141 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -7,8 +7,11 @@ import kr.co.vividnext.sodalive.content.category.CategoryRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -22,8 +25,12 @@ import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.util.LinkedMultiValueMap import org.springframework.web.client.RestTemplate +import javax.persistence.EntityManager +import javax.persistence.LockModeType /** * 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트. @@ -37,13 +44,16 @@ enum class LanguageDetectTargetType { SERIES, ORIGINAL_WORK, - CREATOR_CONTENT_CATEGORY + CREATOR_CONTENT_CATEGORY, + CREATOR_COMMUNITY } class LanguageDetectEvent( val id: Long, val query: String, - val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT + val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT, + val sourceRevision: Long? = null, + val targetLanguage: String? = null ) data class PapagoLanguageDetectResponse( @@ -60,7 +70,10 @@ class LanguageDetectListener( private val seriesRepository: ContentSeriesRepository, private val originalWorkRepository: OriginalWorkRepository, private val categoryRepository: CategoryRepository, + private val creatorCommunityRepository: CreatorCommunityRepository, private val languageDetectionCacheService: LanguageDetectionCacheService, + private val creatorCommunityTranslationService: CreatorCommunityTranslationService, + private val entityManager: EntityManager, private val applicationEventPublisher: ApplicationEventPublisher, @@ -95,6 +108,7 @@ class LanguageDetectListener( LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event) LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event) + LanguageDetectTargetType.CREATOR_COMMUNITY -> handleCreatorCommunityLanguageDetect(event) } } @@ -366,6 +380,53 @@ class LanguageDetectListener( ) } + private fun handleCreatorCommunityLanguageDetect(event: LanguageDetectEvent) { + val sourceRevision = event.sourceRevision ?: return + val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(event.id) ?: return + if ( + creatorCommunity.contentRevision != sourceRevision || + creatorCommunity.content != event.query + ) { + return + } + + if (!creatorCommunity.languageCode.isNullOrBlank()) { + requestTranslationsAfterCommit(event) + return + } + + val languageCode = detectLanguageCode(event, event.id) ?: return + if (languageCode !in getTranslatableLanguageCodes(null)) return + + val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return + entityManager.refresh(currentCreatorCommunity, LockModeType.PESSIMISTIC_WRITE) + if ( + currentCreatorCommunity.contentRevision != sourceRevision || + currentCreatorCommunity.content != event.query + ) { + return + } + + if (!currentCreatorCommunity.languageCode.isNullOrBlank()) { + requestTranslationsAfterCommit(event) + return + } + + currentCreatorCommunity.languageCode = languageCode + creatorCommunityRepository.save(currentCreatorCommunity) + requestTranslationsAfterCommit(event) + } + + private fun requestTranslationsAfterCommit(event: LanguageDetectEvent) { + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + creatorCommunityTranslationService.requestTranslations(event.id, event.targetLanguage) + } + } + ) + } + private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? { return languageDetectionCacheService.detectWithCache(event.query) { requestPapagoLanguageCode(event.query, targetIdForLog) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt new file mode 100644 index 00000000..2ccd3785 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt @@ -0,0 +1,813 @@ +package kr.co.vividnext.sodalive.content + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +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.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler +import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer +import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository +import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler +import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory +import kr.co.vividnext.sodalive.i18n.translation.TranslationMemoryRepository +import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer +import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +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.adapter.out.persistence.CreatorCommunityTranslation +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository +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.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.aop.support.AopUtils +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import org.springframework.test.util.AopTestUtils +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.util.UUID +import java.util.concurrent.Callable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=" + + "jdbc:h2:mem:creator-community-language-detect;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE", + "cloud.naver.papago-client-id=test-client-id", + "cloud.naver.papago-client-secret=test-client-secret" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import( + QueryDslConfig::class, + AudioContentThemeQueryRepository::class, + LanguageDetectListener::class, + CreatorCommunityTranslationService::class, + TranslationSourceExtractor::class, + TranslationReadModelMaterializer::class, + ResourceTranslationJobScheduler::class, + TranslationJobScheduler::class, + CreatorCommunityLanguageDetectTest.AsyncConfiguration::class +) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class CreatorCommunityLanguageDetectTest @Autowired constructor( + private val creatorCommunityRepository: CreatorCommunityRepository, + private val memberRepository: MemberRepository, + private val translationJobRepository: TranslationJobRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + private val languageDetectListener: LanguageDetectListener, + private val creatorCommunityTranslationService: CreatorCommunityTranslationService, + private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository, + private val creatorCommunityService: CreatorCommunityService, + private val languageDetectionCacheService: ControlledLanguageDetectionCacheService, + private val translationMemoryRepository: TranslationMemoryRepository, + private val translationReadModelMaterializer: TranslationReadModelMaterializer, + private val detailPipelineTaskExecutor: DetailPipelineTaskExecutor, + transactionManager: PlatformTransactionManager +) { + private val transactionTemplate = TransactionTemplate(transactionManager) + + @BeforeEach + fun setUp() { + languageDetectionCacheService.clear() + } + + @Test + @DisplayName("커뮤니티 감지 이벤트는 기존 기본값을 유지하면서 현재 본문 메타데이터를 전달한다") + fun shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata() { + val fields = LanguageDetectEvent::class.java.declaredFields.map { it.name } + + assertTrue(LanguageDetectTargetType.values().any { it.name == "CREATOR_COMMUNITY" }) + assertTrue(fields.contains("sourceRevision")) + assertTrue(fields.contains("targetLanguage")) + assertEquals(LanguageDetectTargetType.CONTENT, LanguageDetectEvent(1L, "legacy").targetType) + } + + @Test + @DisplayName("감지 후 번역 요청 서비스는 독립 프록시 트랜잭션을 사용한다") + fun shouldExposeProxiedRequiresNewTranslationRequestEntryPoint() { + val annotation = AopUtils.getTargetClass(creatorCommunityTranslationService) + .getMethod("requestTranslations", Long::class.javaPrimitiveType, String::class.java) + .getAnnotation(Transactional::class.java) + + assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService)) + assertEquals(Propagation.REQUIRES_NEW, annotation.propagation) + } + + @Test + @DisplayName("상세 파이프라인 executor는 이전 작업 완료로 다음 작업 latch를 해제하지 않는다") + fun shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned() { + val executor = DetailPipelineTaskExecutor() + val firstStarted = CountDownLatch(1) + val firstRelease = CountDownLatch(1) + val secondStarted = CountDownLatch(1) + val secondRelease = CountDownLatch(1) + executor.initialize() + + try { + executor.prepareNextTask() + executor.execute { + firstStarted.countDown() + check(firstRelease.await(5, TimeUnit.SECONDS)) + } + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)) + + executor.prepareNextTask() + executor.execute { + secondStarted.countDown() + check(secondRelease.await(5, TimeUnit.SECONDS)) + } + firstRelease.countDown() + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)) + + assertEquals(false, executor.awaitTaskCompletion(0, TimeUnit.MILLISECONDS)) + } finally { + firstRelease.countDown() + secondRelease.countDown() + executor.shutdown() + assertTrue(executor.threadPoolExecutor.awaitTermination(5, TimeUnit.SECONDS)) + } + } + + @Test + @DisplayName("커밋 후 감지는 요청 언어의 번역 작업을 실제로 저장한다") + fun shouldPersistRequestedTargetJobAfterCommittedDetection() { + val postId = persistPost(content = "current source", contentRevision = 3) + givenDetectedLanguage("current source", "ko") + + publishAfterCommit( + LanguageDetectEvent( + id = postId, + query = "current source", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 3, + targetLanguage = "ja" + ) + ) + + assertJobTargets(postId, setOf("ja")) + assertPost(postId, content = "current source", languageCode = "ko") + } + + @Test + @DisplayName("상세 조회는 실제 프록시 감지와 작업 저장 뒤 재료화한 요청 언어 번역문을 반환한다") + fun shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler() { + val sourceContent = "detail source content" + val postId = persistPost(content = sourceContent, contentRevision = 0, isCommentAvailable = false) + val blockingDetection = languageDetectionCacheService.block(sourceContent, "en") + detailPipelineTaskExecutor.prepareNextTask() + + try { + assertTrue(AopUtils.isAopProxy(creatorCommunityService)) + assertTrue(AopUtils.isAopProxy(creatorCommunityTranslationService)) + + val initialDetail = creatorCommunityService.getCommunityPostDetail( + postId = postId, + memberId = 999L, + timezone = "Asia/Seoul", + isAdult = true + ) + + assertEquals(sourceContent, initialDetail.content) + assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS)) + assertEquals(emptySet(), jobTargets(postId)) + } finally { + blockingDetection.release.countDown() + } + + assertTrue(detailPipelineTaskExecutor.awaitTaskCompletion(5, TimeUnit.SECONDS)) + assertPost(postId, content = sourceContent, languageCode = "en") + assertEquals(setOf("ko"), jobTargets(postId)) + assertEquals(1, jobCount(postId)) + + val repeatedDetail = creatorCommunityService.getCommunityPostDetail( + postId = postId, + memberId = 999L, + timezone = "Asia/Seoul", + isAdult = true + ) + assertEquals(sourceContent, repeatedDetail.content) + + val executor = Executors.newFixedThreadPool(2) + try { + val repeatedDetails = listOf( + executor.submit( + Callable { + creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content + } + ), + executor.submit( + Callable { + creatorCommunityService.getCommunityPostDetail(postId, 999L, "Asia/Seoul", true).content + } + ) + ).map { it.get(5, TimeUnit.SECONDS) } + + assertEquals(listOf(sourceContent, sourceContent), repeatedDetails) + } finally { + executor.shutdownNow() + } + assertEquals(1, jobCount(postId)) + + transactionTemplate.executeWithoutResult { + translationMemoryRepository.save( + TranslationMemory( + sourceHash = SourceTextNormalizer.hash(sourceContent), + sourceText = sourceContent, + sourceLanguage = "en", + targetLanguage = "ko", + translatedText = "translated detail content", + provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER, + providerVersion = "nmt-v1" + ) + ) + } + assertTrue( + translationReadModelMaterializer.materialize( + LanguageTranslationTargetType.CREATOR_COMMUNITY, + postId, + "ko" + ) + ) + + val translatedDetail = creatorCommunityService.getCommunityPostDetail( + postId = postId, + memberId = 999L, + timezone = "Asia/Seoul", + isAdult = true + ) + + assertEquals("translated detail content", translatedDetail.content) + assertEquals(1, jobCount(postId)) + } + + @Test + @DisplayName("전체 범위 감지는 원문 언어를 제외한 실제 번역 작업을 저장한다") + fun shouldPersistAllNonSourceTargetJobsAfterCommittedDetection() { + val postId = persistPost(content = "English source", contentRevision = 1) + givenDetectedLanguage("English source", "en") + + publishAfterCommit( + LanguageDetectEvent( + id = postId, + query = "English source", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 1 + ) + ) + + assertJobTargets(postId, setOf("ja", "ko")) + assertPost(postId, content = "English source", languageCode = "en") + } + + @Test + @DisplayName("감지 중 변경된 본문은 refresh 뒤에도 이전 언어와 작업을 적용하지 않는다") + fun shouldRejectStalePostAfterBlockedDetection() { + val postId = persistPost(content = "before", contentRevision = 0) + val blockingDetection = languageDetectionCacheService.block("before", "ko") + val executor = Executors.newSingleThreadExecutor() + + try { + val completion = executor.submit { + detectInTransaction( + LanguageDetectEvent( + id = postId, + query = "before", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 0, + targetLanguage = "ja" + ) + ) + } + assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS)) + updatePost(postId, content = "after", contentRevision = 1) + blockingDetection.release.countDown() + completion.get(5, TimeUnit.SECONDS) + + assertPost(postId, content = "after", languageCode = null) + assertEquals(emptySet(), jobTargets(postId)) + } finally { + blockingDetection.release.countDown() + executor.shutdownNow() + } + } + + @Test + @DisplayName("동시 감지 중 첫 감지가 언어를 저장해도 두 번째 요청 언어를 예약한다") + fun shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage() { + val postId = persistPost(content = "concurrent source", contentRevision = 0) + val concurrentDetection = languageDetectionCacheService.blockConcurrent("concurrent source", "ko") + val executor = Executors.newFixedThreadPool(2) + + try { + val first = executor.submit { + detectInTransaction( + LanguageDetectEvent( + id = postId, + query = "concurrent source", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 0, + targetLanguage = "ja" + ) + ) + } + assertTrue(concurrentDetection.firstStarted.await(5, TimeUnit.SECONDS)) + + val second = executor.submit { + detectInTransaction( + LanguageDetectEvent( + id = postId, + query = "concurrent source", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 0, + targetLanguage = "en" + ) + ) + } + assertTrue(concurrentDetection.secondStarted.await(5, TimeUnit.SECONDS)) + + concurrentDetection.firstRelease.countDown() + first.get(5, TimeUnit.SECONDS) + concurrentDetection.secondRelease.countDown() + second.get(5, TimeUnit.SECONDS) + + assertPost(postId, content = "concurrent source", languageCode = "ko") + assertJobTargets(postId, setOf("en", "ja")) + } finally { + concurrentDetection.firstRelease.countDown() + concurrentDetection.secondRelease.countDown() + executor.shutdownNow() + } + } + + @Test + @DisplayName("언어가 이미 확정된 동시 목표 이벤트는 둘 다 번역 작업으로 이어진다") + fun shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown() { + val postId = persistPost(content = "known source", contentRevision = 2, languageCode = "ko") + + publishAfterCommit( + LanguageDetectEvent( + id = postId, + query = "known source", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 2, + targetLanguage = "ja" + ) + ) + publishAfterCommit( + LanguageDetectEvent( + id = postId, + query = "known source", + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = 2, + targetLanguage = "en" + ) + ) + + assertJobTargets(postId, setOf("en", "ja")) + assertTrue(languageDetectionCacheService.queries.isEmpty()) + } + + @Test + @DisplayName("빈 본문과 미지원 감지값과 감지 실패는 원문과 작업 상태를 유지한다") + fun shouldKeepOriginalWhenDetectionCannotProduceSupportedLanguage() { + val blankPostId = persistPost(content = " ", contentRevision = 0) + val unsupportedPostId = persistPost(content = "unsupported", contentRevision = 0) + val failedPostId = persistPost(content = "provider failure", contentRevision = 0) + givenDetectedLanguage("unsupported", "fr") + + publishAfterCommit(communityEvent(blankPostId, " ", 0)) + publishAfterCommit(communityEvent(unsupportedPostId, "unsupported", 0)) + publishAfterCommit(communityEvent(failedPostId, "provider failure", 0)) + + assertTrue(await { languageDetectionCacheService.queries.contains("unsupported") }) + assertTrue(await { languageDetectionCacheService.queries.contains("provider failure") }) + assertPost(blankPostId, content = " ", languageCode = null) + assertPost(unsupportedPostId, content = "unsupported", languageCode = null) + assertPost(failedPostId, content = "provider failure", languageCode = null) + assertEquals(emptySet(), jobTargets(blankPostId)) + assertEquals(emptySet(), jobTargets(unsupportedPostId)) + assertEquals(emptySet(), jobTargets(failedPostId)) + } + + @Test + @DisplayName("실제 프록시 생성 트랜잭션이 롤백되면 게시글과 번역 후속 작업을 남기지 않는다") + fun shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack() { + val creator = persistCreator() + val content = "rolled back community ${UUID.randomUUID()}" + val postId = transactionTemplate.execute { status -> + creatorCommunityService.createCommunityPost( + audioFile = null, + postImage = null, + requestString = createRequest(content), + member = creator + ) + val post = creatorCommunityRepository.findAll().single { it.content == content } + status.setRollbackOnly() + post.id!! + }!! + + assertTrue(AopUtils.isAopProxy(creatorCommunityService)) + assertTrue(transactionTemplate.execute { creatorCommunityRepository.findById(postId).isEmpty }!!) + assertEquals(emptySet(), jobTargets(postId)) + assertTrue( + transactionTemplate.execute { + creatorCommunityTranslationRepository.findAll().none { it.creatorCommunityId == postId } + }!! + ) + assertTrue(languageDetectionCacheService.queries.isEmpty()) + } + + @Test + @DisplayName("실제 영문 본문 수정은 이전 번역을 숨기고 커밋 후 영문 감지와 새 작업으로 이어진다") + fun shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification() { + val creator = persistCreator() + val koreanContent = "기존 한국어 본문" + val englishContent = "current English community source" + val postId = transactionTemplate.execute { + val post = creatorCommunityRepository.save( + CreatorCommunity( + content = koreanContent, + price = 0, + isCommentAvailable = true, + isAdult = false, + languageCode = "ko", + contentRevision = 0 + ).apply { + member = creator + } + ) + creatorCommunityTranslationRepository.save( + CreatorCommunityTranslation( + creatorCommunityId = post.id!!, + locale = "en", + content = "old English translation", + sourceRevision = 0, + sourceHash = SourceTextNormalizer.hash(koreanContent), + sourceLanguage = "ko" + ) + ) + post.id!! + }!! + val blockingDetection = languageDetectionCacheService.block(englishContent, "en") + val executor = Executors.newSingleThreadExecutor() + + try { + val modification = executor.submit { + transactionTemplate.executeWithoutResult { + creatorCommunityService.modifyCommunityPost( + postImage = null, + requestString = modifyRequest(postId, englishContent), + member = creator + ) + } + } + assertTrue(blockingDetection.started.await(5, TimeUnit.SECONDS)) + + transactionTemplate.executeWithoutResult { + val post = creatorCommunityRepository.findById(postId).orElseThrow() + assertEquals(englishContent, post.content) + assertNull(post.languageCode) + assertEquals(1, post.contentRevision) + assertEquals( + englishContent, + creatorCommunityTranslationService.findDisplayContents(listOf(postId), "en")[postId]?.content + ) + assertEquals( + "old English translation", + creatorCommunityTranslationRepository + .findByCreatorCommunityIdAndLocale(postId, "en") + ?.content + ) + assertEquals(emptySet(), jobTargets(postId)) + } + + blockingDetection.release.countDown() + modification.get(5, TimeUnit.SECONDS) + + assertTrue( + await { + transactionTemplate.execute { + creatorCommunityRepository.findById(postId).orElseThrow().languageCode == "en" + } == true + } + ) + assertPost(postId, content = englishContent, languageCode = "en") + assertJobTargets(postId, setOf("ja", "ko")) + } finally { + blockingDetection.release.countDown() + executor.shutdownNow() + } + } + + private fun communityEvent(postId: Long, content: String, sourceRevision: Long): LanguageDetectEvent { + return LanguageDetectEvent( + id = postId, + query = content, + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = sourceRevision + ) + } + + private fun givenDetectedLanguage(content: String, detectedLanguage: String) { + languageDetectionCacheService.detections[content] = detectedLanguage + } + + private fun createRequest(content: String): String { + return """{"content":"$content","price":0,"isCommentAvailable":true,"isAdult":false}""" + } + + private fun modifyRequest(postId: Long, content: String): String { + return """{"creatorCommunityId":$postId,"content":"$content"}""" + } + + private fun publishAfterCommit(event: LanguageDetectEvent) { + transactionTemplate.executeWithoutResult { + applicationEventPublisher.publishEvent(event) + } + } + + private fun detectInTransaction(event: LanguageDetectEvent) { + val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener) + transactionTemplate.executeWithoutResult { + listener.detectLanguage(event) + } + } + + private fun persistPost( + content: String, + contentRevision: Long, + languageCode: String? = null, + isCommentAvailable: Boolean = true + ): Long { + return transactionTemplate.execute { + val member = memberRepository.save( + Member( + password = "password", + nickname = "language-detect-${UUID.randomUUID()}", + role = MemberRole.CREATOR + ) + ) + creatorCommunityRepository.save( + CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = isCommentAvailable, + isAdult = false, + languageCode = languageCode, + contentRevision = contentRevision + ).apply { + this.member = member + } + ).id!! + }!! + } + + private fun persistCreator(): Member { + return transactionTemplate.execute { + memberRepository.save( + Member( + password = "password", + nickname = "community-service-${UUID.randomUUID()}", + role = MemberRole.CREATOR + ) + ) + }!! + } + + private fun updatePost(postId: Long, content: String, contentRevision: Long) { + transactionTemplate.executeWithoutResult { + val post = creatorCommunityRepository.findById(postId).orElseThrow() + post.content = content + post.contentRevision = contentRevision + post.languageCode = null + } + } + + private fun assertPost(postId: Long, content: String, languageCode: String?) { + val post = transactionTemplate.execute { + creatorCommunityRepository.findById(postId).orElseThrow() + }!! + + assertEquals(content, post.content) + if (languageCode == null) { + assertNull(post.languageCode) + } else { + assertEquals(languageCode, post.languageCode) + } + } + + private fun assertJobTargets(postId: Long, expectedTargets: Set) { + assertTrue(await { jobTargets(postId) == expectedTargets }) + } + + private fun jobTargets(postId: Long): Set { + return transactionTemplate.execute { + translationJobRepository.findAll() + .filter { + it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId + } + .map { it.targetLanguage } + .toSet() + }.orEmpty() + } + + private fun jobCount(postId: Long): Int { + return transactionTemplate.execute { + translationJobRepository.findAll().count { + it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && it.resourceId == postId + } + }!! + } + + private fun await(condition: () -> Boolean): Boolean { + repeat(50) { + if (condition()) return true + Thread.sleep(100) + } + return condition() + } + + @TestConfiguration + @EnableAsync + class AsyncConfiguration { + @Bean(name = ["taskExecutor"]) + fun taskExecutor(): DetailPipelineTaskExecutor { + return DetailPipelineTaskExecutor() + } + + @Bean + @Primary + fun languageDetectionCacheService( + languageDetectionResultRepository: LanguageDetectionResultRepository + ): ControlledLanguageDetectionCacheService { + return ControlledLanguageDetectionCacheService(languageDetectionResultRepository) + } + + @Bean + fun creatorCommunityService( + repository: CreatorCommunityRepository, + blockMemberRepository: BlockMemberRepository, + likeRepository: CreatorCommunityLikeRepository, + commentRepository: CreatorCommunityCommentRepository, + useCanRepository: UseCanRepository, + applicationEventPublisher: ApplicationEventPublisher, + creatorCommunityTranslationService: CreatorCommunityTranslationService, + entityManager: EntityManager + ): CreatorCommunityService { + return CreatorCommunityService( + canPaymentService = Mockito.mock(CanPaymentService::class.java), + repository = repository, + blockMemberRepository = blockMemberRepository, + likeRepository = likeRepository, + commentRepository = commentRepository, + useCanRepository = useCanRepository, + s3Uploader = Mockito.mock(S3Uploader::class.java), + objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()), + audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), + applicationEventPublisher = applicationEventPublisher, + messageSource = SodaMessageSource(), + langContext = LangContext(), + homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java), + creatorCommunityTranslationService = creatorCommunityTranslationService, + entityManager = entityManager, + imageBucket = "test-image-bucket", + contentBucket = "test-content-bucket", + imageHost = "https://test.cloudfront.net" + ) + } + } +} + +class ControlledLanguageDetectionCacheService( + languageDetectionResultRepository: LanguageDetectionResultRepository +) : LanguageDetectionCacheService(languageDetectionResultRepository) { + val detections = ConcurrentHashMap() + val queries = ConcurrentLinkedQueue() + private val blockingDetections = ConcurrentHashMap() + private val concurrentDetections = ConcurrentHashMap() + + fun clear() { + detections.clear() + queries.clear() + blockingDetections.clear() + concurrentDetections.clear() + } + + fun block(query: String, detectedLanguage: String): BlockingDetection { + return BlockingDetection(detectedLanguage).also { blockingDetections[query] = it } + } + + fun blockConcurrent(query: String, detectedLanguage: String): ConcurrentDetection { + return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it } + } + + override fun detectWithCache(query: String, provider: String, detector: () -> String?): String? { + queries.add(query) + val concurrentDetection = concurrentDetections[query] + if (concurrentDetection != null) { + return concurrentDetection.detect() + } + val blockingDetection = blockingDetections[query] + if (blockingDetection != null) { + blockingDetection.started.countDown() + check(blockingDetection.release.await(5, TimeUnit.SECONDS)) + return blockingDetection.detectedLanguage + } + return detections[query] + } +} + +class BlockingDetection(val detectedLanguage: String) { + val started = CountDownLatch(1) + val release = CountDownLatch(1) +} + +class ConcurrentDetection(private val detectedLanguage: String) { + private val order = AtomicInteger() + val firstStarted = CountDownLatch(1) + val secondStarted = CountDownLatch(1) + val firstRelease = CountDownLatch(1) + val secondRelease = CountDownLatch(1) + + fun detect(): String { + when (order.incrementAndGet()) { + 1 -> { + firstStarted.countDown() + check(firstRelease.await(5, TimeUnit.SECONDS)) + } + + 2 -> { + secondStarted.countDown() + check(secondRelease.await(5, TimeUnit.SECONDS)) + } + } + return detectedLanguage + } +} + +class DetailPipelineTaskExecutor : ThreadPoolTaskExecutor() { + private val completion = AtomicReference(CountDownLatch(0)) + + init { + corePoolSize = 1 + maxPoolSize = 1 + setThreadNamePrefix("detail-pipeline-") + setTaskDecorator { task -> + val taskCompletion = completion.get() + Runnable { + try { + task.run() + } finally { + taskCompletion.countDown() + } + } + } + } + + fun prepareNextTask() { + completion.set(CountDownLatch(1)) + } + + fun awaitTaskCompletion(timeout: Long, unit: TimeUnit): Boolean { + return completion.get().await(timeout, unit) + } +}