feat(creator-community): 게시물 번역 서비스를 추가한다

This commit is contained in:
2026-09-10 15:11:22 +09:00
parent 3e9ba0b23e
commit 6efecf5495
3 changed files with 1044 additions and 1 deletions
@@ -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"
} }
@@ -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
)
@@ -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
)
}
}