refactor(i18n): 공급자 전환과 캐시 선택을 공통 처리로 연결한다

This commit is contained in:
2026-11-09 15:41:13 +09:00
parent 320adb037c
commit f7fadf7ac7
16 changed files with 545 additions and 221 deletions
@@ -15,9 +15,14 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.Creato
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.DetectedLanguage
import kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProvider
import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinator
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
@@ -83,6 +88,9 @@ import javax.persistence.EntityManager
CreatorCommunityTranslationService::class,
TranslationSourceExtractor::class,
TranslationReadModelMaterializer::class,
TranslationCoordinator::class,
LanguageProviderProperties::class,
PapagoTranslationService::class,
ResourceTranslationJobScheduler::class,
TranslationJobScheduler::class,
CreatorCommunityLanguageDetectTest.AsyncConfiguration::class
@@ -255,7 +263,7 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor(
sourceLanguage = "en",
targetLanguage = "ko",
translatedText = "translated detail content",
provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER,
provider = "papago",
providerVersion = "nmt-v1"
)
)
@@ -719,7 +727,14 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor(
class ControlledLanguageDetectionCacheService(
languageDetectionResultRepository: LanguageDetectionResultRepository
) : LanguageDetectionCacheService(languageDetectionResultRepository) {
) : LanguageDetectionCacheService(
languageDetectionResultRepository,
listOf(object : LanguageDetectionProvider {
override val providerName = "papago"
override fun detect(query: String): String? = null
}),
LanguageProviderProperties()
) {
val detections = ConcurrentHashMap<String, String>()
val queries = ConcurrentLinkedQueue<String>()
private val blockingDetections = ConcurrentHashMap<String, BlockingDetection>()
@@ -740,19 +755,19 @@ class ControlledLanguageDetectionCacheService(
return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it }
}
override fun detectWithCache(query: String, provider: String, detector: () -> String?): String? {
override fun detectWithCache(query: String): DetectedLanguage? {
queries.add(query)
val concurrentDetection = concurrentDetections[query]
if (concurrentDetection != null) {
return concurrentDetection.detect()
return DetectedLanguage(concurrentDetection.detect(), "papago")
}
val blockingDetection = blockingDetections[query]
if (blockingDetection != null) {
blockingDetection.started.countDown()
check(blockingDetection.release.await(5, TimeUnit.SECONDS))
return blockingDetection.detectedLanguage
return DetectedLanguage(blockingDetection.detectedLanguage, "papago")
}
return detections[query]
return detections[query]?.let { DetectedLanguage(it, "papago") }
}
}
@@ -4,9 +4,13 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoLanguageDetectionProvider
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinator
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus
@@ -67,6 +71,10 @@ import java.util.concurrent.atomic.AtomicInteger
QueryDslConfig::class,
AudioContentThemeQueryRepository::class,
LanguageDetectionCacheService::class,
LanguageProviderProperties::class,
PapagoLanguageDetectionProvider::class,
PapagoTranslationService::class,
TranslationCoordinator::class,
LanguageDetectListener::class,
CreatorCommunityTranslationService::class,
TranslationSourceExtractor::class,
@@ -82,6 +90,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct
private val translationJobRepository: TranslationJobRepository,
private val languageDetectionCacheService: LanguageDetectionCacheService,
private val languageDetectListener: LanguageDetectListener,
private val papagoLanguageDetectionProvider: PapagoLanguageDetectionProvider,
transactionManager: PlatformTransactionManager
) {
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
@@ -92,8 +101,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct
@BeforeEach
fun setUp() {
papagoRequests = ControlledPapagoRequestFactory()
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
ReflectionTestUtils.setField(listener, "restTemplate", RestTemplate(papagoRequests))
ReflectionTestUtils.setField(papagoLanguageDetectionProvider, "restTemplate", RestTemplate(papagoRequests))
}
@Test
@@ -232,7 +240,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct
val rows = requiresNewTransactionTemplate.execute {
languageDetectionResultRepository.findAll().filter {
it.sourceHash == SourceTextNormalizer.hash(content) &&
it.provider == LanguageDetectionCacheService.DEFAULT_PROVIDER &&
it.provider == "papago" &&
it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION
}
}.orEmpty()
@@ -1,5 +1,8 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.i18n.translation.DetectedLanguage
import kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProvider
import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
@@ -10,7 +13,8 @@ class LanguageDetectionCacheServiceTest {
@Test
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
val service = LanguageDetectionCacheService(repository)
var providerCalls = 0
val service = service(repository) { providerCalls++; "ko" }
val sourceHash = SourceTextNormalizer.hash("Hello world")
Mockito.`when`(
@@ -30,13 +34,9 @@ class LanguageDetectionCacheServiceTest {
)
)
var providerCalls = 0
val detected = service.detectWithCache("Hello world") {
providerCalls++
"ko"
}
val detected = service.detectWithCache("Hello world")
assertEquals("en", detected)
assertEquals(DetectedLanguage("en", "papago"), detected)
assertEquals(0, providerCalls)
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
@@ -50,7 +50,7 @@ class LanguageDetectionCacheServiceTest {
@DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다")
fun shouldReturnStoredWinnerAfterInitialCacheInsert() {
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
val service = LanguageDetectionCacheService(repository)
val service = service(repository) { "en" }
val sourceHash = SourceTextNormalizer.hash("Hello world")
Mockito.`when`(
@@ -78,9 +78,9 @@ class LanguageDetectionCacheServiceTest {
)
).thenReturn("ko")
val detected = service.detectWithCache("Hello world") { "en" }
val detected = service.detectWithCache("Hello world")
assertEquals("ko", detected)
assertEquals(DetectedLanguage("ko", "papago"), detected)
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
provider = "papago",
@@ -101,4 +101,12 @@ class LanguageDetectionCacheServiceTest {
)
Mockito.verifyNoMoreInteractions(repository)
}
private fun service(repository: LanguageDetectionResultRepository, detect: () -> String?): LanguageDetectionCacheService {
val provider = object : LanguageDetectionProvider {
override val providerName = "papago"
override fun detect(query: String) = detect()
}
return LanguageDetectionCacheService(repository, listOf(provider), LanguageProviderProperties())
}
}
@@ -0,0 +1,108 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService
import kr.co.vividnext.sodalive.content.LanguageDetectionResult
import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LanguageDetectionProviderTest {
private val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
private val query = "original source"
private val hash = SourceTextNormalizer.hash(query)
@Test
@DisplayName("감지 예외와 빈 결과는 다음 공급자로 넘기고 실제 공급자의 저장 승자를 반환한다")
fun shouldFallbackAndReturnCanonicalWinnerWithActualProvider() {
val failures: List<() -> String?> = listOf({ throw IllegalStateException("sensitive body") }, { null }, { " " })
failures.forEach { failure ->
val first = RecordingDetectionProvider("first", failure)
val second = RecordingDetectionProvider("second") { "EN" }
Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "second", "v1")).thenReturn("ko")
assertEquals(DetectedLanguage("ko", "second"), service(listOf(first, second)).detectWithCache(query))
assertEquals(listOf(query), first.queries)
assertEquals(listOf(query), second.queries)
}
Mockito.verify(repository, Mockito.times(3)).insertIfAbsent(hash, "original source", "en", "second", null, "v1")
}
@Test
@DisplayName("보조 공급자의 유효 캐시가 있으면 첫 공급자도 호출하지 않는다")
fun shouldCheckAllConfiguredCachesBeforeCallingAnyProvider() {
val first = RecordingDetectionProvider("first") { "ko" }
val second = RecordingDetectionProvider("second") { "en" }
Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "second", "v1"))
.thenReturn(LanguageDetectionResult(hash, "sample", "ja", "second", null, "v1"))
assertEquals(DetectedLanguage("ja", "second"), service(listOf(first, second)).detectWithCache(query))
assertEquals(emptyList<String>(), first.queries + second.queries)
}
@Test
@DisplayName("정상 미지원 언어 감지는 성공이며 후속 공급자를 호출하지 않는다")
fun shouldKeepUnsupportedDetectionAsSuccessAndHonorOrder() {
val first = RecordingDetectionProvider("first") { "ko" }
val second = RecordingDetectionProvider("second") { "FR" }
Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "second", "v1")).thenReturn("fr")
assertEquals(
DetectedLanguage("fr", "second"),
service(listOf(first, second), listOf("second", "first")).detectWithCache(query)
)
assertEquals(emptyList<String>(), first.queries)
assertEquals(listOf(query), second.queries)
}
@Test
@DisplayName("감지 전체 실패와 공백 입력은 null이고 공백은 저장소와 공급자를 호출하지 않는다")
fun shouldReturnNullAfterAllFailuresAndSkipBlankInput() {
val first = RecordingDetectionProvider("first") { null }
val second = RecordingDetectionProvider("second") { null }
val service = service(listOf(first, second))
assertNull(service.detectWithCache(" \n"))
Mockito.verifyNoInteractions(repository)
assertNull(service.detectWithCache(query))
assertEquals(listOf(query), first.queries)
assertEquals(listOf(query), second.queries)
}
@Test
@DisplayName("감지 저장소 조회와 저장 오류는 다음 공급자로 전환하지 않고 전파한다")
fun shouldPropagateRepositoryFailuresWithoutFallback() {
val first = RecordingDetectionProvider("first") { "ko" }
val second = RecordingDetectionProvider("second") { "en" }
val service = service(listOf(first, second))
val failure = IllegalStateException("database unavailable")
Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "first", "v1"))
.thenThrow(failure)
assertSame(failure, assertThrows(IllegalStateException::class.java) { service.detectWithCache(query) })
assertEquals(emptyList<String>(), first.queries + second.queries)
Mockito.reset(repository)
Mockito.`when`(repository.insertIfAbsent(hash, "original source", "ko", "first", null, "v1")).thenThrow(failure)
assertSame(failure, assertThrows(IllegalStateException::class.java) { service.detectWithCache(query) })
assertEquals(listOf(query), first.queries)
assertEquals(emptyList<String>(), second.queries)
}
private fun service(providers: List<LanguageDetectionProvider>, order: List<String> = providers.map { it.providerName }) =
LanguageDetectionCacheService(repository, providers, LanguageProviderProperties(detectionOrder = order.joinToString(",")))
}
class RecordingDetectionProvider(
override val providerName: String,
private val response: () -> String?
) : LanguageDetectionProvider {
val queries = mutableListOf<String>()
override fun detect(query: String): String? {
queries.add(query)
return response()
}
}
@@ -0,0 +1,178 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService
import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.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.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.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.SpyBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.UUID
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:language-provider-cache;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
"sodalive.language-providers.detection-order=papago,secondary",
"sodalive.language-providers.translation-order=papago,secondary"
]
)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(
QueryDslConfig::class,
AudioContentThemeQueryRepository::class,
LanguageProviderProperties::class,
LanguageDetectionCacheService::class,
TranslationCoordinator::class,
TranslationJobWorker::class,
TranslationReadModelMaterializer::class,
TranslationSourceExtractor::class,
TranslationJobScheduler::class,
ResourceTranslationJobScheduler::class,
CreatorCommunityTranslationService::class,
LanguageProviderCacheEndToEndTest.Providers::class
)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class LanguageProviderCacheEndToEndTest @Autowired constructor(
private val detector: LanguageDetectionCacheService,
private val detectionRepository: LanguageDetectionResultRepository,
private val memoryRepository: TranslationMemoryRepository,
private val scheduler: TranslationJobScheduler,
private val worker: TranslationJobWorker,
private val materializer: TranslationReadModelMaterializer,
private val displayService: CreatorCommunityTranslationService,
private val posts: CreatorCommunityRepository,
private val members: MemberRepository,
private val translations: CreatorCommunityTranslationRepository,
private val papagoDetection: RecordingDetectionProvider,
private val secondaryDetection: RecordingDetectionProvider,
private val papagoTranslation: RecordingTranslationProvider,
private val secondaryTranslation: RecordingTranslationProvider,
transactionManager: PlatformTransactionManager
) {
private val transaction = TransactionTemplate(transactionManager)
@SpyBean
private lateinit var jobRepository: TranslationJobRepository
@BeforeEach
fun useH2CompatibleClaimQuery() {
Mockito.doAnswer {
jobRepository.findAll().filter { it.status == TranslationJobStatus.PENDING }.mapNotNull { it.id }
}.`when`(jobRepository).findNextPendingGroupJobIdsForUpdate(Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now())
}
@Test
@DisplayName("보조 공급자의 감지를 H2에 귀속 저장하고 외부 호출 없이 캐시를 재사용한다")
fun shouldPersistSecondaryDetectionAndReuseCache() {
val source = "provider source ${UUID.randomUUID()}"
assertEquals(DetectedLanguage("ko", "secondary"), detector.detectWithCache(source))
assertEquals(DetectedLanguage("ko", "secondary"), detector.detectWithCache(source))
val detection = transaction.execute {
detectionRepository.findBySourceHashAndProviderAndNormalizationVersion(
SourceTextNormalizer.hash(source),
"secondary",
"v1"
)
}!!
assertEquals("secondary", detection.provider)
assertEquals("ko", detection.detectedLanguage)
assertEquals(1, papagoDetection.queries.size)
assertEquals(1, secondaryDetection.queries.size)
}
@Test
@DisplayName("보조 공급자의 번역을 H2에 귀속 저장하고 무호출 캐시와 실제 표시를 재사용한다")
fun shouldPersistSecondaryTranslationAndReuseCacheThroughWorkerAndMaterializer() {
val source = "translation source ${UUID.randomUUID()}"
val postId = persistPost(source)
schedule(postId, source)
assertTrue(worker.processNextGroup())
val memory = transaction.execute {
memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
SourceTextNormalizer.hash(source),
"ko",
"en",
"secondary",
"v1"
)
}
assertNotNull(memory)
assertEquals("secondary", memory!!.provider)
assertEquals("secondary-v1", memory.providerVersion)
assertEquals("secondary:0", memory.translatedText)
assertEquals(
TranslationJobStatus.COMPLETED,
transaction.execute { jobRepository.findAll().single { it.resourceId == postId }.status }
)
assertEquals("secondary:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content)
transaction.executeWithoutResult {
translations.deleteAll()
jobRepository.deleteAll()
}
assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en"))
schedule(postId, source)
assertTrue(worker.processNextGroup())
assertEquals(1, papagoTranslation.requests.size)
assertEquals(1, secondaryTranslation.requests.size)
assertEquals(listOf(source), secondaryTranslation.requests.single().texts)
assertEquals("secondary:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content)
}
private fun schedule(postId: Long, source: String) {
scheduler.scheduleMissingTranslation(
LanguageTranslationTargetType.CREATOR_COMMUNITY,
postId,
"content",
source,
"ko",
"en"
)
}
private fun persistPost(source: String): Long = transaction.execute {
val member = members.save(
Member(password = "password", nickname = "provider-${UUID.randomUUID()}", role = MemberRole.CREATOR)
)
posts.save(CreatorCommunity(source, 0, true, false, languageCode = "ko").apply { this.member = member }).id!!
}!!
@TestConfiguration
class Providers {
@Bean
fun papagoDetection() = RecordingDetectionProvider("papago") { throw IllegalStateException("sensitive provider body") }
@Bean
fun secondaryDetection() = RecordingDetectionProvider("secondary") { "ko" }
@Bean
fun papagoTranslation() = RecordingTranslationProvider("papago") { TranslateResult(emptyList()) }
@Bean
fun secondaryTranslation() = RecordingTranslationProvider("secondary")
}
}
@@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService
import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
class LanguageProviderSpringConfigurationTest {
private val runner = ApplicationContextRunner().withUserConfiguration(Providers::class.java)
@Test
@DisplayName("실제 Spring 설정 바인딩으로 감지만 순서를 바꾸어도 번역 순서는 유지한다")
fun shouldBindIndependentCapabilityOrders() {
listOf("papago,secondary" to "papago", "secondary,papago" to "secondary").forEach { (order, expected) ->
runner.withPropertyValues(
"sodalive.language-providers.detection-order=$order",
"sodalive.language-providers.translation-order=papago,secondary"
).run { context ->
assertNull(context.startupFailure)
val detector = context.getBean(LanguageDetectionCacheService::class.java)
assertEquals(expected, detector.detectWithCache("source")?.providerName)
assertEquals(
"papago",
context.getBean(TranslationCoordinator::class.java)
.translate(TranslateRequest(listOf("source"), "ko", "en")).providerName
)
val secondary = context.getBean("secondaryDetection", RecordingDetectionProvider::class.java)
assertEquals(if (expected == "secondary") 1 else 0, secondary.queries.size)
assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size)
}
}
runner.withPropertyValues("sodalive.language-providers.translation-order=secondary,papago").run { context ->
assertNull(context.startupFailure)
assertEquals(
"papago",
context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerName
)
assertEquals(
"secondary",
context.getBean(TranslationCoordinator::class.java)
.translate(TranslateRequest(listOf("source"), "ko", "en")).providerName
)
}
}
@Test
@DisplayName("Spring 기본값은 두 기능 모두 파파고이며 잘못된 설정은 시작 시 실패한다")
fun shouldDefaultToPapagoAndRejectInvalidConfigurationAtStartup() {
runner.run { context ->
assertNull(context.startupFailure)
val properties = context.getBean(LanguageProviderProperties::class.java)
assertEquals(listOf("papago"), properties.detectionOrder)
assertEquals(listOf("papago"), properties.translationOrder)
}
listOf("detection-order", "translation-order").forEach { key ->
listOf("", "papago,papago", "unknown").forEach { value ->
runner.withPropertyValues("sodalive.language-providers.$key=$value").run { context ->
assertNotNull(context.startupFailure)
}
}
}
}
@TestConfiguration
@Import(LanguageProviderProperties::class, TranslationCoordinator::class, LanguageDetectionCacheService::class)
class Providers {
@Bean
fun memoryRepository(): TranslationMemoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
@Bean
fun detectionRepository(): LanguageDetectionResultRepository {
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
listOf("papago", "secondary").forEach { provider ->
Mockito.`when`(repository.findDetectedLanguageForUpdate(SourceTextNormalizer.hash("source"), provider, "v1"))
.thenReturn("ko")
}
return repository
}
@Bean
fun papagoDetection() = RecordingDetectionProvider("papago") { "ko" }
@Bean
fun secondaryDetection() = RecordingDetectionProvider("secondary") { "ko" }
@Bean
fun papagoTranslation() = RecordingTranslationProvider("papago")
@Bean
fun secondaryTranslation() = RecordingTranslationProvider("secondary")
}
}
@@ -30,7 +30,7 @@ class TranslationJobWorkerTest {
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository),
materializer = materializer,
transactionManager = TestTransactionManager()
)
@@ -54,7 +54,7 @@ class TranslationJobWorkerTest {
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository),
materializer = materializer,
transactionManager = TestTransactionManager()
)
@@ -84,7 +84,7 @@ class TranslationJobWorkerTest {
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository),
materializer = materializer,
transactionManager = TestTransactionManager()
)
@@ -129,7 +129,7 @@ class TranslationJobWorkerTest {
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository),
materializer = materializer,
transactionManager = TestTransactionManager()
)
@@ -165,7 +165,7 @@ class TranslationJobWorkerTest {
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository),
materializer = materializer,
transactionManager = TestTransactionManager()
)
@@ -198,7 +198,7 @@ class TranslationJobWorkerTest {
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository),
materializer = materializer,
transactionManager = TestTransactionManager()
)
@@ -213,7 +213,7 @@ class TranslationJobWorkerTest {
assertEquals(TranslationJobStatus.PENDING, job.status)
assertEquals(1, job.retryCount)
assertEquals("provider down", job.lastErrorMessage)
assertEquals("all translation providers failed", job.lastErrorMessage)
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
}
@@ -19,9 +19,12 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio
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.LanguageProviderProperties
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinator
import kr.co.vividnext.sodalive.i18n.translation.TranslationJob
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
@@ -668,7 +671,11 @@ class CreatorCommunityTranslationServiceTest @Autowired constructor(
): TranslationReadModelMaterializer {
return TranslationReadModelMaterializer(
sourceExtractor = sourceExtractor,
translationMemoryRepository = memoryRepository,
translationCoordinator = TranslationCoordinator(
listOf(PapagoTranslationService("test-id", "test-secret")),
LanguageProviderProperties(),
memoryRepository
),
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java),