From 6efecf5495febbf4d959573e2d0beebb260124fa Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:11:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EB=B2=88=EC=97=AD=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TranslationReadModelMaterializer.kt | 54 +- .../CreatorCommunityTranslationService.kt | 113 +++ .../CreatorCommunityTranslationServiceTest.kt | 878 ++++++++++++++++++ 3 files changed, 1044 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt index 6b93f0b9..7adcc50b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt @@ -18,8 +18,13 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +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 org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager +import javax.persistence.LockModeType @Service class TranslationReadModelMaterializer( @@ -31,7 +36,10 @@ class TranslationReadModelMaterializer( private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, private val originalWorkTranslationRepository: OriginalWorkTranslationRepository, - private val categoryTranslationRepository: CategoryTranslationRepository + private val categoryTranslationRepository: CategoryTranslationRepository, + private val creatorCommunityRepository: CreatorCommunityRepository, + private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository, + private val entityManager: EntityManager ) { @Transactional fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean { @@ -46,6 +54,9 @@ class TranslationReadModelMaterializer( LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations) LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations) LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations) + LanguageTranslationTargetType.CREATOR_COMMUNITY -> { + return upsertCreatorCommunity(source, targetLanguage.lowercase(), translations) + } } return true @@ -180,6 +191,47 @@ class TranslationReadModelMaterializer( } } + private fun upsertCreatorCommunity( + source: TranslationSource, + targetLanguage: String, + translations: Map + ): Boolean { + val sourceRevision = source.sourceRevision ?: return false + val sourceHash = SourceTextNormalizer.hash(source.fields.single().sourceText) + val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(source.resourceId) ?: return false + entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE) + if ( + !creatorCommunity.isActive || + creatorCommunity.contentRevision != sourceRevision || + creatorCommunity.languageCode?.lowercase() != source.sourceLanguage.lowercase() || + SourceTextNormalizer.hash(creatorCommunity.content) != sourceHash + ) { + return false + } + + val existing = creatorCommunityTranslationRepository + .findByCreatorCommunityIdAndLocaleForUpdate(source.resourceId, targetLanguage) + if (existing == null) { + creatorCommunityTranslationRepository.save( + CreatorCommunityTranslation( + creatorCommunityId = source.resourceId, + locale = targetLanguage, + content = translations["content"].orEmpty(), + sourceRevision = sourceRevision, + sourceHash = sourceHash, + sourceLanguage = source.sourceLanguage.lowercase() + ) + ) + } else { + existing.content = translations["content"].orEmpty() + existing.sourceRevision = sourceRevision + existing.sourceHash = sourceHash + existing.sourceLanguage = source.sourceLanguage.lowercase() + creatorCommunityTranslationRepository.save(existing) + } + return true + } + companion object { const val DEFAULT_PROVIDER = "papago" } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt new file mode 100644 index 00000000..e51a61e0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt @@ -0,0 +1,113 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application + +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes +import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler +import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer +import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer +import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor +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 org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager +import javax.persistence.LockModeType + +@Service +class CreatorCommunityTranslationService( + private val creatorCommunityRepository: CreatorCommunityRepository, + private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository, + private val sourceExtractor: TranslationSourceExtractor, + private val materializer: TranslationReadModelMaterializer, + private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, + private val applicationEventPublisher: ApplicationEventPublisher, + private val entityManager: EntityManager +) { + @Transactional(readOnly = true) + fun findDisplayContents(postIds: List, locale: String): Map { + if (postIds.isEmpty()) return emptyMap() + + val normalizedLocale = locale.lowercase() + val posts = creatorCommunityRepository.findAllById(postIds).filter { it.isActive } + val translations = creatorCommunityTranslationRepository + .findByCreatorCommunityIdInAndLocale(posts.mapNotNull { it.id }, normalizedLocale) + .associateBy { it.creatorCommunityId } + + return posts.associate { post -> + post.id!! to translations[post.id].displayContentOr(post, normalizedLocale) + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun requestTranslations(postId: Long, targetLanguage: String? = null) { + val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(postId) ?: return + entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE) + + val source = sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId) + if (source == null) { + requestLanguageDetection(postId, targetLanguage) + return + } + + val sourceLanguage = source.sourceLanguage.lowercase() + val targetLanguages = targetLanguage?.let { setOf(it.lowercase()) } + ?: getTranslatableLanguageCodes(sourceLanguage) + + targetLanguages + .filter { it != sourceLanguage } + .forEach { language -> + if (!materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, language)) { + resourceTranslationJobScheduler.scheduleResourceTranslation( + resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY, + resourceId = postId, + targetLanguage = language + ) + } + } + } + + private fun requestLanguageDetection(postId: Long, targetLanguage: String?) { + val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(postId) ?: return + if (!creatorCommunity.languageCode.isNullOrBlank() || creatorCommunity.content.isBlank()) return + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = postId, + query = creatorCommunity.content, + targetType = LanguageDetectTargetType.CREATOR_COMMUNITY, + sourceRevision = creatorCommunity.contentRevision, + targetLanguage = targetLanguage + ) + ) + } + + private fun CreatorCommunityTranslation?.displayContentOr( + creatorCommunity: CreatorCommunity, + locale: String + ): CreatorCommunityDisplayContent { + val sourceLanguage = creatorCommunity.languageCode?.lowercase() + return if ( + this != null && + sourceLanguage != null && + sourceLanguage != locale && + sourceRevision == creatorCommunity.contentRevision && + sourceLanguage == this.sourceLanguage.lowercase() && + sourceHash == SourceTextNormalizer.hash(creatorCommunity.content) + ) { + CreatorCommunityDisplayContent(content = content, hasValidTranslation = true) + } else { + CreatorCommunityDisplayContent(content = creatorCommunity.content, hasValidTranslation = false) + } + } +} + +data class CreatorCommunityDisplayContent( + val content: String, + val hasValidTranslation: Boolean +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt new file mode 100644 index 00000000..caf3cb0b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt @@ -0,0 +1,878 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.translation + +import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository +import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.content.category.CategoryRepository +import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +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.TranslationJob +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.TranslationSource +import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor +import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceField +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.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 org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +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.context.ApplicationEventPublisher +import org.springframework.context.annotation.Import +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +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.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:creator-community-translation;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig::class) +class CreatorCommunityTranslationServiceTest @Autowired constructor( + private val persistentCreatorCommunityRepository: CreatorCommunityRepository, + private val persistentMemberRepository: MemberRepository, + private val persistentTranslationRepository: CreatorCommunityTranslationRepository, + private val persistentMemoryRepository: TranslationMemoryRepository, + private val persistentTranslationJobRepository: TranslationJobRepository, + private val entityManager: EntityManager, + private val transactionManager: PlatformTransactionManager +) { + private val transactionTemplate = TransactionTemplate(transactionManager) + private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply { + propagationBehavior = Propagation.REQUIRES_NEW.value() + } + private val repeatableReadTransactionTemplate = TransactionTemplate(transactionManager).apply { + isolationLevel = TransactionDefinition.ISOLATION_REPEATABLE_READ + } + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @Test + fun shouldRequireCreatorCommunityTranslationPersistenceContract() { + val fieldNames = CreatorCommunity::class.java.declaredFields.map { it.name } + + assertTrue(fieldNames.contains("languageCode")) + assertTrue(fieldNames.contains("contentRevision")) + assertTrue(LanguageTranslationTargetType.values().any { it.name == "CREATOR_COMMUNITY" }) + } + + @Test + @DisplayName("표시 조회는 현재 개정과 원문 언어가 일치하는 번역만 일괄 조회해 반환한다") + fun shouldReturnOnlyCurrentTranslationFromBatchedRead() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) + val service = CreatorCommunityTranslationService( + creatorCommunityRepository, + translationRepository, + sourceExtractor, + materializer, + scheduler, + applicationEventPublisher, + Mockito.mock(EntityManager::class.java) + ) + val current = creatorCommunity(postId = 1L, content = "current", languageCode = "ko", contentRevision = 2) + val staleRevision = creatorCommunity(postId = 2L, content = "changed", languageCode = "ko", contentRevision = 3) + val sameLanguage = creatorCommunity(postId = 3L, content = "original", languageCode = "en", contentRevision = 2) + val inactive = creatorCommunity(postId = 4L, content = "hidden", languageCode = "ko", contentRevision = 2).apply { + isActive = false + } + val currentTranslation = translation(postId = 1L, content = "translated", sourceRevision = 2, sourceText = "current") + val staleTranslation = translation(postId = 2L, content = "old translated", sourceRevision = 2, sourceText = "changed") + val sameLanguageTranslation = translation( + postId = 3L, + content = "should not show", + sourceRevision = 2, + sourceText = "original", + sourceLanguage = "en" + ) + + Mockito.`when`(creatorCommunityRepository.findAllById(listOf(1L, 2L, 3L, 4L))) + .thenReturn(listOf(current, staleRevision, sameLanguage, inactive)) + Mockito.`when`(translationRepository.findByCreatorCommunityIdInAndLocale(listOf(1L, 2L, 3L), "en")) + .thenReturn(listOf(currentTranslation, staleTranslation, sameLanguageTranslation)) + + val contents = service.findDisplayContents(listOf(1L, 2L, 3L, 4L), "EN") + + assertEquals("translated", contents[1L]?.content) + assertTrue(contents[1L]?.hasValidTranslation == true) + assertEquals("changed", contents[2L]?.content) + assertTrue(contents[2L]?.hasValidTranslation == false) + assertEquals("original", contents[3L]?.content) + assertTrue(contents[3L]?.hasValidTranslation == false) + assertTrue(4L !in contents) + Mockito.verifyNoInteractions(sourceExtractor, materializer, scheduler) + } + + @Test + @DisplayName("번역이 없거나 원문 언어와 해시가 다르면 현재 원문을 반환한다") + fun shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) + val service = CreatorCommunityTranslationService( + creatorCommunityRepository, + translationRepository, + sourceExtractor, + materializer, + scheduler, + applicationEventPublisher, + Mockito.mock(EntityManager::class.java) + ) + val missing = creatorCommunity(1L, "missing", "ko", 1) + val sourceLanguageMismatch = creatorCommunity(2L, "language", "ko", 1) + val sourceHashMismatch = creatorCommunity(3L, "current", "ko", 1) + val languageMismatchTranslation = translation( + postId = 2L, + content = "wrong language", + sourceRevision = 1, + sourceText = "language", + sourceLanguage = "ja" + ) + val hashMismatchTranslation = translation( + postId = 3L, + content = "wrong hash", + sourceRevision = 1, + sourceText = "previous" + ) + + Mockito.`when`(creatorCommunityRepository.findAllById(listOf(1L, 2L, 3L))) + .thenReturn(listOf(missing, sourceLanguageMismatch, sourceHashMismatch)) + Mockito.`when`(translationRepository.findByCreatorCommunityIdInAndLocale(listOf(1L, 2L, 3L), "en")) + .thenReturn(listOf(languageMismatchTranslation, hashMismatchTranslation)) + + val contents = service.findDisplayContents(listOf(1L, 2L, 3L), "en") + + assertEquals("missing", contents[1L]?.content) + assertTrue(contents[1L]?.hasValidTranslation == false) + assertEquals("language", contents[2L]?.content) + assertTrue(contents[2L]?.hasValidTranslation == false) + assertEquals("current", contents[3L]?.content) + assertTrue(contents[3L]?.hasValidTranslation == false) + } + + @Test + @DisplayName("현재 원문의 메모리를 재료화할 수 있으면 작업을 새로 예약하지 않는다") + fun shouldRematerializeFromMemoryBeforeScheduling() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) + val service = CreatorCommunityTranslationService( + creatorCommunityRepository, + translationRepository, + sourceExtractor, + materializer, + scheduler, + applicationEventPublisher, + Mockito.mock(EntityManager::class.java) + ) + val source = TranslationSource( + resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY, + resourceId = 1L, + sourceLanguage = "ko", + fields = listOf(TranslationSourceField("content", "A")), + sourceRevision = 2 + ) + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)) + .thenReturn(creatorCommunity(1L, "A", "ko", 2)) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")) + .thenReturn(true) + + service.requestTranslations(1L, "EN") + + Mockito.verify(materializer).materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en") + Mockito.verifyNoInteractions(scheduler) + } + + @Test + @DisplayName("현재 원문의 메모리가 없으면 기존 작업 예약기를 사용한다") + fun shouldScheduleMissingTranslationAfterMemoryCannotMaterialize() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) + val service = CreatorCommunityTranslationService( + creatorCommunityRepository, + translationRepository, + sourceExtractor, + materializer, + scheduler, + applicationEventPublisher, + Mockito.mock(EntityManager::class.java) + ) + val source = TranslationSource( + resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY, + resourceId = 1L, + sourceLanguage = "ko", + fields = listOf(TranslationSourceField("content", "B")), + sourceRevision = 3 + ) + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)) + .thenReturn(creatorCommunity(1L, "B", "ko", 3)) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")) + .thenReturn(false) + + service.requestTranslations(1L, "en") + + Mockito.verify(scheduler).scheduleResourceTranslation( + LanguageTranslationTargetType.CREATOR_COMMUNITY, + 1L, + "en" + ) + } + + @Test + @DisplayName("상세 요청 언어가 원문 언어와 같으면 번역 작업을 예약하지 않는다") + fun shouldNotScheduleTranslationWhenDetailTargetLanguageEqualsSourceLanguage() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) + val service = CreatorCommunityTranslationService( + creatorCommunityRepository, + translationRepository, + sourceExtractor, + materializer, + scheduler, + applicationEventPublisher, + Mockito.mock(EntityManager::class.java) + ) + val post = creatorCommunity(postId = 1L, content = "original", languageCode = "ja", contentRevision = 0) + val source = translationSource(content = "original", sourceLanguage = "ja", sourceRevision = 0) + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(post) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + + service.requestTranslations(1L, "ja") + + Mockito.verifyNoInteractions(materializer, scheduler) + } + + @Test + @DisplayName("언어가 없는 현재 커뮤니티 본문은 감지 이벤트에 개정과 요청 언어를 보존한다") + fun shouldPublishCurrentContentDetectionEventWhenSourceLanguageIsUnknown() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) + val service = CreatorCommunityTranslationService( + creatorCommunityRepository, + translationRepository, + sourceExtractor, + materializer, + scheduler, + applicationEventPublisher, + Mockito.mock(EntityManager::class.java) + ) + val creatorCommunity = CreatorCommunity( + content = "current", + price = 0, + isCommentAvailable = true, + isAdult = false, + contentRevision = 2 + ).apply { + id = 1L + } + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(creatorCommunity) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(null) + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrue(1L)).thenReturn(creatorCommunity) + + service.requestTranslations(1L, "ja") + + val eventCaptor = org.mockito.ArgumentCaptor.forClass(LanguageDetectEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(1L, eventCaptor.value.id) + assertEquals("current", eventCaptor.value.query) + assertEquals(LanguageDetectTargetType.CREATOR_COMMUNITY, eventCaptor.value.targetType) + assertEquals(2L, eventCaptor.value.sourceRevision) + assertEquals("ja", eventCaptor.value.targetLanguage) + Mockito.verifyNoInteractions(materializer, scheduler) + } + + @Test + @DisplayName("이전 본문으로 돌아오면 번역 메모리에서 현재 개정의 조회 모델을 복원한다") + fun shouldRestoreReturnedContentFromTranslationMemoryAtCurrentRevision() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val materializer = materializer( + sourceExtractor, + memoryRepository, + creatorCommunityRepository, + translationRepository + ) + val source = translationSource(content = "A", sourceLanguage = "ko", sourceRevision = 2) + val currentPost = creatorCommunity(postId = 1L, content = "A", languageCode = "ko", contentRevision = 2) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + givenTranslationMemory(memoryRepository, "A", "ko") + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(currentPost) + Mockito.`when`(translationRepository.findByCreatorCommunityIdAndLocale(1L, "en")).thenReturn(null) + + assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")) + + val translationCaptor = org.mockito.ArgumentCaptor.forClass(CreatorCommunityTranslation::class.java) + Mockito.verify(translationRepository).save(translationCaptor.capture()) + assertEquals("translated A", translationCaptor.value.content) + assertEquals(2L, translationCaptor.value.sourceRevision) + assertEquals(SourceTextNormalizer.hash("A"), translationCaptor.value.sourceHash) + assertEquals("ko", translationCaptor.value.sourceLanguage) + } + + @Test + @DisplayName("원문 추출 뒤 본문 개정이 바뀌면 번역 결과를 저장하지 않는다") + fun shouldNotStoreTranslationWhenRevisionChangesBeforeLockedWrite() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val materializer = materializer( + sourceExtractor, + memoryRepository, + creatorCommunityRepository, + translationRepository + ) + val source = translationSource(content = "before", sourceLanguage = "ko", sourceRevision = 0) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + givenTranslationMemory(memoryRepository, "before", "ko") + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)) + .thenReturn(creatorCommunity(1L, "after", "ko", 1)) + + assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")) + + Mockito.verifyNoInteractions(translationRepository) + } + + @Test + @DisplayName("원문 언어가 바뀌면 번역 결과를 저장하지 않는다") + fun shouldNotStoreTranslationWhenSourceLanguageChangesBeforeLockedWrite() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val materializer = materializer( + sourceExtractor, + memoryRepository, + creatorCommunityRepository, + translationRepository + ) + val source = translationSource(content = "same", sourceLanguage = "ko", sourceRevision = 0) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + givenTranslationMemory(memoryRepository, "same", "ko") + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)) + .thenReturn(creatorCommunity(1L, "same", "en", 0)) + + assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")) + + Mockito.verifyNoInteractions(translationRepository) + } + + @Test + @DisplayName("원문 게시물이 비활성화되면 번역 결과를 저장하지 않는다") + fun shouldNotStoreTranslationWhenPostBecomesInactiveBeforeLockedWrite() { + val creatorCommunityRepository = Mockito.mock(CreatorCommunityRepository::class.java) + val translationRepository = Mockito.mock(CreatorCommunityTranslationRepository::class.java) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val materializer = materializer( + sourceExtractor, + memoryRepository, + creatorCommunityRepository, + translationRepository + ) + val source = translationSource(content = "before", sourceLanguage = "ko", sourceRevision = 0) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L)).thenReturn(source) + givenTranslationMemory(memoryRepository, "before", "ko") + Mockito.`when`(creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(1L)).thenReturn(null) + + assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, 1L, "en")) + + Mockito.verifyNoInteractions(translationRepository) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("이미 관리 중인 게시물이 다른 트랜잭션에서 수정되면 이전 번역을 저장하지 않는다") + fun shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit() { + val postId = persistCreatorCommunity("stale-A", 0) + persistTranslationMemory("stale-A") + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = persistentMaterializer(sourceExtractor) + val source = translationSource(postId, "stale-A", "ko", 0) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenReturn(source) + + transactionTemplate.executeWithoutResult { + persistentCreatorCommunityRepository.findById(postId).orElseThrow() + updateCreatorCommunity(postId, "stale-B", 1) + + assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + } + + val translation = requiresNewTransactionTemplate.execute { + persistentTranslationRepository.findByCreatorCommunityIdAndLocale(postId, "en") + } + assertEquals(null, translation) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("본문 A에서 B를 거쳐 A로 돌아오면 기존 번역 행을 현재 개정으로 갱신한다") + fun shouldRematerializeAtoBtoAIntoExistingTranslationRow() { + val postId = persistCreatorCommunity("restore-A", 0) + persistTranslationMemory("restore-A") + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = persistentMaterializer(sourceExtractor) + val firstSource = translationSource(postId, "restore-A", "ko", 0) + val restoredSource = translationSource(postId, "restore-A", "ko", 2) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)) + .thenReturn(firstSource, restoredSource) + + requiresNewTransactionTemplate.executeWithoutResult { + assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + } + updateCreatorCommunity(postId, "restore-B", 1) + updateCreatorCommunity(postId, "restore-A", 2) + requiresNewTransactionTemplate.executeWithoutResult { + assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + } + + val translation = requiresNewTransactionTemplate.execute { + persistentTranslationRepository.findByCreatorCommunityIdAndLocale(postId, "en") + }!! + assertEquals("translated restore-A", translation.content) + assertEquals(2L, translation.sourceRevision) + assertEquals(SourceTextNormalizer.hash("restore-A"), translation.sourceHash) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동시 재료화는 게시물 잠금과 locale 유일 키로 하나의 번역 행만 저장한다") + fun shouldSerializeConcurrentCreatorCommunityUpserts() { + val postId = persistCreatorCommunity("concurrent-A", 2) + persistTranslationMemory("concurrent-A") + val source = translationSource(postId, "concurrent-A", "ko", 2) + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = persistentMaterializer(sourceExtractor) + val readyToLock = CountDownLatch(2) + val releaseLock = CountDownLatch(1) + val completed = CountDownLatch(2) + val errors = ConcurrentLinkedQueue() + val executor = Executors.newFixedThreadPool(2) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenAnswer { + readyToLock.countDown() + if (!releaseLock.await(5, TimeUnit.SECONDS)) { + throw IllegalStateException("workers did not begin together") + } + source + } + + try { + repeat(2) { + executor.submit { + try { + transactionTemplate.executeWithoutResult { + materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en") + } + } catch (error: Throwable) { + errors.add(error) + } finally { + completed.countDown() + } + } + } + + assertTrue(readyToLock.await(5, TimeUnit.SECONDS)) + releaseLock.countDown() + assertTrue(completed.await(10, TimeUnit.SECONDS)) + assertTrue(errors.isEmpty()) + } finally { + releaseLock.countDown() + executor.shutdownNow() + } + + val translations = transactionTemplate.execute { + persistentTranslationRepository.findByCreatorCommunityIdInAndLocale(listOf(postId), "en") + }.orEmpty() + assertEquals(1, translations.size) + assertEquals("translated concurrent-A", translations.single().content) + assertEquals(2L, translations.single().sourceRevision) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 게시물의 누락 번역 예약은 하나의 작업만 만들고 둘 다 정상 종료한다") + fun shouldSerializeConcurrentMissingMemoryTranslationJobScheduling() { + val postId = persistCreatorCommunity("concurrent-job", 0) + val missingJobReads = CountDownLatch(2) + val jobRepository = delayingTranslationJobRepository(postId, "concurrent-job", missingJobReads) + val service = persistentService(jobRepository) + val start = CountDownLatch(1) + val completed = CountDownLatch(2) + val errors = ConcurrentLinkedQueue() + val executor = Executors.newFixedThreadPool(2) + + try { + repeat(2) { + executor.submit { + try { + check(start.await(5, TimeUnit.SECONDS)) + requiresNewTransactionTemplate.executeWithoutResult { + service.requestTranslations(postId, "en") + } + } catch (error: Throwable) { + errors.add(error) + } finally { + completed.countDown() + } + } + } + + start.countDown() + assertTrue(completed.await(10, TimeUnit.SECONDS)) + } finally { + start.countDown() + executor.shutdownNow() + } + + assertTrue(errors.isEmpty()) + val jobs = requiresNewTransactionTemplate.execute { + persistentTranslationJobRepository.findAll().filter { + it.resourceType == LanguageTranslationTargetType.CREATOR_COMMUNITY && + it.resourceId == postId && + it.targetLanguage == "en" + } + }.orEmpty() + assertEquals(1, jobs.size) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("반복 읽기 스냅샷 뒤 번역 행을 잠가 기존 행을 갱신한다") + fun shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter() { + val postId = persistCreatorCommunity("repeatable-A", 0) + persistTranslationMemory("repeatable-A") + val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java) + val materializer = persistentMaterializer(sourceExtractor) + val source = translationSource(postId, "repeatable-A", "ko", 0) + Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)).thenReturn(source) + val snapshotReady = CountDownLatch(2) + val startFirstWriter = CountDownLatch(1) + val firstWriterCommitted = CountDownLatch(1) + val completed = CountDownLatch(2) + val errors = ConcurrentLinkedQueue() + val executor = Executors.newFixedThreadPool(2) + + try { + executor.submit { + try { + repeatableReadTransactionTemplate.executeWithoutResult { + loadTranslationMemoryForSnapshot("repeatable-A") + snapshotReady.countDown() + startFirstWriter.await() + assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + } + } catch (error: Throwable) { + errors.add(error) + } finally { + firstWriterCommitted.countDown() + completed.countDown() + } + } + executor.submit { + try { + repeatableReadTransactionTemplate.executeWithoutResult { + loadTranslationMemoryForSnapshot("repeatable-A") + snapshotReady.countDown() + firstWriterCommitted.await() + assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + } + } catch (error: Throwable) { + errors.add(error) + } finally { + completed.countDown() + } + } + + assertTrue(snapshotReady.await(5, TimeUnit.SECONDS)) + startFirstWriter.countDown() + assertTrue(completed.await(10, TimeUnit.SECONDS)) + assertTrue(errors.isEmpty()) + } finally { + startFirstWriter.countDown() + firstWriterCommitted.countDown() + executor.shutdownNow() + } + + val translations = requiresNewTransactionTemplate.execute { + persistentTranslationRepository.findByCreatorCommunityIdInAndLocale(listOf(postId), "en") + }.orEmpty() + assertEquals(1, translations.size) + assertEquals("translated repeatable-A", translations.single().content) + } + + private fun materializer( + sourceExtractor: TranslationSourceExtractor, + memoryRepository: TranslationMemoryRepository, + creatorCommunityRepository: CreatorCommunityRepository, + translationRepository: CreatorCommunityTranslationRepository, + entityManager: EntityManager = Mockito.mock(EntityManager::class.java) + ): TranslationReadModelMaterializer { + return TranslationReadModelMaterializer( + sourceExtractor = sourceExtractor, + translationMemoryRepository = memoryRepository, + contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), + aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java), + contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java), + seriesTranslationRepository = Mockito.mock(SeriesTranslationRepository::class.java), + seriesGenreTranslationRepository = Mockito.mock(SeriesGenreTranslationRepository::class.java), + originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java), + categoryTranslationRepository = Mockito.mock(CategoryTranslationRepository::class.java), + creatorCommunityRepository = creatorCommunityRepository, + creatorCommunityTranslationRepository = translationRepository, + entityManager = entityManager + ) + } + + private fun persistentMaterializer(sourceExtractor: TranslationSourceExtractor): TranslationReadModelMaterializer { + return materializer( + sourceExtractor, + persistentMemoryRepository, + persistentCreatorCommunityRepository, + persistentTranslationRepository, + entityManager + ) + } + + private fun persistentService(translationJobRepository: TranslationJobRepository): CreatorCommunityTranslationService { + val sourceExtractor = TranslationSourceExtractor( + audioContentRepository = Mockito.mock(AudioContentRepository::class.java), + chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java), + audioContentThemeRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java), + seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java), + seriesGenreRepository = Mockito.mock(AdminContentSeriesGenreRepository::class.java), + originalWorkRepository = Mockito.mock(OriginalWorkRepository::class.java), + categoryRepository = Mockito.mock(CategoryRepository::class.java), + creatorCommunityRepository = persistentCreatorCommunityRepository + ) + return CreatorCommunityTranslationService( + creatorCommunityRepository = persistentCreatorCommunityRepository, + creatorCommunityTranslationRepository = persistentTranslationRepository, + sourceExtractor = sourceExtractor, + materializer = persistentMaterializer(sourceExtractor), + resourceTranslationJobScheduler = ResourceTranslationJobScheduler( + sourceExtractor, + TranslationJobScheduler(translationJobRepository) + ), + applicationEventPublisher = applicationEventPublisher, + entityManager = entityManager + ) + } + + private fun delayingTranslationJobRepository( + postId: Long, + sourceText: String, + missingJobReads: CountDownLatch + ): TranslationJobRepository { + val sourceHash = SourceTextNormalizer.hash(sourceText) + val translationJobRepository = Mockito.mock(TranslationJobRepository::class.java) + Mockito.`when`( + translationJobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash( + LanguageTranslationTargetType.CREATOR_COMMUNITY, + postId, + "content", + "en", + sourceHash + ) + ).thenAnswer { + val existing = persistentTranslationJobRepository + .findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash( + LanguageTranslationTargetType.CREATOR_COMMUNITY, + postId, + "content", + "en", + sourceHash + ) + if (existing == null) { + missingJobReads.countDown() + missingJobReads.await(1, TimeUnit.SECONDS) + } + existing + } + Mockito.`when`(translationJobRepository.save(Mockito.any(TranslationJob::class.java))).thenAnswer { + persistentTranslationJobRepository.save(it.getArgument(0)) + } + return translationJobRepository + } + + private fun translationSource( + postId: Long = 1L, + content: String, + sourceLanguage: String, + sourceRevision: Long + ): TranslationSource { + return TranslationSource( + resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY, + resourceId = postId, + sourceLanguage = sourceLanguage, + fields = listOf(TranslationSourceField("content", content)), + sourceRevision = sourceRevision + ) + } + + private fun translationMemory(sourceText: String, sourceLanguage: String): TranslationMemory { + return TranslationMemory( + sourceHash = SourceTextNormalizer.hash(sourceText), + sourceText = sourceText, + sourceLanguage = sourceLanguage, + targetLanguage = "en", + translatedText = "translated $sourceText", + provider = "papago", + providerVersion = "nmt-v1" + ) + } + + private fun persistCreatorCommunity(content: String, contentRevision: Long): Long { + return requiresNewTransactionTemplate.execute { + val member = Member( + password = "password", + nickname = "translation-${UUID.randomUUID()}", + role = MemberRole.CREATOR + ) + val post = CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = true, + isAdult = false, + languageCode = "ko", + contentRevision = contentRevision + ).apply { + this.member = persistentMemberRepository.save(member) + } + persistentCreatorCommunityRepository.save(post).id!! + }!! + } + + private fun persistTranslationMemory(sourceText: String) { + requiresNewTransactionTemplate.executeWithoutResult { + persistentMemoryRepository.save(translationMemory(sourceText, "ko")) + } + } + + private fun updateCreatorCommunity(postId: Long, content: String, contentRevision: Long) { + requiresNewTransactionTemplate.executeWithoutResult { + val post = persistentCreatorCommunityRepository.findById(postId).orElseThrow() + post.content = content + post.contentRevision = contentRevision + } + } + + private fun loadTranslationMemoryForSnapshot(sourceText: String) { + persistentMemoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( + SourceTextNormalizer.hash(sourceText), + "ko", + "en", + "papago", + SourceTextNormalizer.NORMALIZATION_VERSION + ) + } + + private fun givenTranslationMemory( + memoryRepository: TranslationMemoryRepository, + sourceText: String, + sourceLanguage: String + ) { + Mockito.`when`( + memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( + SourceTextNormalizer.hash(sourceText), + sourceLanguage, + "en", + "papago", + SourceTextNormalizer.NORMALIZATION_VERSION + ) + ).thenReturn(translationMemory(sourceText, sourceLanguage)) + } + + private fun creatorCommunity( + postId: Long, + content: String, + languageCode: String, + contentRevision: Long + ): CreatorCommunity { + return CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = true, + isAdult = false, + languageCode = languageCode, + contentRevision = contentRevision + ).apply { + id = postId + } + } + + private fun translation( + postId: Long, + content: String, + sourceRevision: Long, + sourceText: String, + sourceLanguage: String = "ko" + ): CreatorCommunityTranslation { + return CreatorCommunityTranslation( + creatorCommunityId = postId, + locale = "en", + content = content, + sourceRevision = sourceRevision, + sourceHash = SourceTextNormalizer.hash(sourceText), + sourceLanguage = sourceLanguage + ) + } +}