feat(creator-community): 게시물 번역 서비스를 추가한다
This commit is contained in:
+53
-1
@@ -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.ContentTranslation
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
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.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.LockModeType
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class TranslationReadModelMaterializer(
|
class TranslationReadModelMaterializer(
|
||||||
@@ -31,7 +36,10 @@ class TranslationReadModelMaterializer(
|
|||||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
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
|
@Transactional
|
||||||
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
||||||
@@ -46,6 +54,9 @@ class TranslationReadModelMaterializer(
|
|||||||
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
||||||
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
||||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
||||||
|
LanguageTranslationTargetType.CREATOR_COMMUNITY -> {
|
||||||
|
return upsertCreatorCommunity(source, targetLanguage.lowercase(), translations)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -180,6 +191,47 @@ class TranslationReadModelMaterializer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun upsertCreatorCommunity(
|
||||||
|
source: TranslationSource,
|
||||||
|
targetLanguage: String,
|
||||||
|
translations: Map<String, String>
|
||||||
|
): 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 {
|
companion object {
|
||||||
const val DEFAULT_PROVIDER = "papago"
|
const val DEFAULT_PROVIDER = "papago"
|
||||||
}
|
}
|
||||||
|
|||||||
+113
@@ -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<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent> {
|
||||||
|
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
|
||||||
|
)
|
||||||
+878
@@ -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<Throwable>()
|
||||||
|
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<Throwable>()
|
||||||
|
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<Throwable>()
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user