feat(i18n): 감지·번역 공급자 계약과 독립 순서 설정을 추가한다

This commit is contained in:
2026-11-09 15:40:05 +09:00
parent 55b6aa35e2
commit 320adb037c
11 changed files with 424 additions and 0 deletions
@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class LanguageProviderPropertiesTest {
@Test
@DisplayName("기능별 목록을 서로 독립된 설정 순서로 정렬한다")
fun shouldOrderCapabilitiesIndependently() {
val properties = LanguageProviderProperties("second,first", "first,second")
assertEquals(
listOf("second", "first"),
properties.orderedProviders(properties.detectionOrder, listOf("first", "second")) { it }
)
assertEquals(
listOf("first", "second"),
properties.orderedProviders(properties.translationOrder, listOf("second", "first")) { it }
)
}
@Test
@DisplayName("빈 순서와 빈 이름과 중복 순서와 미등록 공급자는 초기화에서 거부한다")
fun shouldRejectInvalidOrders() {
val properties = LanguageProviderProperties()
listOf(emptyList(), listOf(""), listOf("first", "first"), listOf("unknown")).forEach { order ->
assertThrows(IllegalArgumentException::class.java) {
properties.orderedProviders(order, listOf("first", "second")) { it }
}
}
}
@Test
@DisplayName("같은 기능 안에서 중복 등록된 공급자 이름은 초기화에서 거부한다")
fun shouldRejectDuplicateRegisteredNamesEvenWhenNotSelected() {
assertThrows(IllegalArgumentException::class.java) {
LanguageProviderProperties().orderedProviders(listOf("papago"), listOf("papago", "other", "other")) { it }
}
}
}
@@ -0,0 +1,71 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.content
import org.springframework.test.web.client.match.MockRestRequestMatchers.header
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestTemplate
class PapagoLanguageDetectionProviderTest {
@Test
@DisplayName("파파고 감지는 인증 헤더와 form query 원문을 보존한다")
fun shouldPreservePapagoFormContract() {
val provider = PapagoLanguageDetectionProvider("test-id", "test-secret")
val template = RestTemplate()
val server = MockRestServiceServer.bindTo(template).build()
ReflectionTestUtils.setField(provider, "restTemplate", template)
val form = LinkedMultiValueMap<String, String>().apply { add("query", "Hello world & 안녕") }
server.expect(requestTo("https://papago.apigw.ntruss.com/langs/v1/dect"))
.andExpect(method(HttpMethod.POST))
.andExpect(header("X-NCP-APIGW-API-KEY-ID", "test-id"))
.andExpect(header("X-NCP-APIGW-API-KEY", "test-secret"))
.andExpect(content().contentType(MediaType("application", "x-www-form-urlencoded", Charsets.UTF_8)))
.andExpect(content().formData(form))
.andRespond(withSuccess("""{"langCode":"en"}""", MediaType.APPLICATION_JSON))
assertEquals("en", provider.detect("Hello world & 안녕"))
server.verify()
}
@Test
@DisplayName("빈 응답과 공백 감지와 HTTP 실패는 유효 감지값을 반환하지 않는다")
fun shouldReturnNullForInvalidResponses() {
listOf("", "{}", """{"langCode":" "}""").forEach { body ->
val provider = PapagoLanguageDetectionProvider("test-id", "test-secret")
val template = RestTemplate()
val server = MockRestServiceServer.bindTo(template).build()
ReflectionTestUtils.setField(provider, "restTemplate", template)
server.expect(requestTo("https://papago.apigw.ntruss.com/langs/v1/dect"))
.andRespond(withSuccess(body, MediaType.APPLICATION_JSON))
assertNull(provider.detect("source"))
server.verify()
}
}
@Test
@DisplayName("HTTP 실패는 coordinator에서 처리하도록 전파한다")
fun shouldExposeHttpFailureToCoordinator() {
val provider = PapagoLanguageDetectionProvider("test-id", "test-secret")
val template = RestTemplate()
val server = MockRestServiceServer.bindTo(template).build()
ReflectionTestUtils.setField(provider, "restTemplate", template)
server.expect(requestTo("https://papago.apigw.ntruss.com/langs/v1/dect"))
.andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR))
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.client.RestClientException::class.java) {
provider.detect("source")
}
server.verify()
}
}
@@ -0,0 +1,166 @@
package kr.co.vividnext.sodalive.i18n.translation
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 TranslationCoordinatorTest {
private val repository = Mockito.mock(TranslationMemoryRepository::class.java)
private val request = TranslateRequest(listOf("first source", "second source"), "ko", "en")
@Test
@DisplayName("설정 순서의 첫 성공 공급자와 버전을 반환하고 후속 호출을 멈춘다")
fun shouldUseConfiguredOrderAndStopAfterSuccess() {
val first = RecordingTranslationProvider("first")
val second = RecordingTranslationProvider("second")
val coordinator = coordinator(listOf(first, second), listOf("second", "first"))
val result = coordinator.translate(request)
assertEquals("second", result.providerName)
assertEquals("second-v1", result.providerVersion)
assertEquals(listOf("second:0", "second:1"), result.result.translatedText)
assertEquals(listOf(request), second.requests)
assertEquals(emptyList<TranslateRequest>(), first.requests)
}
@Test
@DisplayName("예외와 부분 결과와 공백 결과 뒤에는 전체 요청을 다음 공급자로 넘긴다")
fun shouldFallbackWholeRequestAfterExceptionsOrInvalidResults() {
val failures: List<(TranslateRequest) -> TranslateResult> = listOf(
{ throw IllegalStateException("sensitive response body") },
{ TranslateResult(listOf("partial")) },
{ TranslateResult(listOf("translated", " ")) }
)
failures.forEach { failure ->
val first = RecordingTranslationProvider("first", failure)
val second = RecordingTranslationProvider("second")
val coordinator = coordinator(listOf(first, second))
assertEquals("second", coordinator.translate(request).providerName)
assertEquals(listOf(request), first.requests)
assertEquals(listOf(request), second.requests)
}
}
@Test
@DisplayName("전체 공급자 실패는 원문이나 원시 예외 원인 없이 고정 메시지를 반환한다")
fun shouldSanitizeAllProviderFailures() {
val first = RecordingTranslationProvider("first") { throw IllegalStateException("sensitive response body") }
val second = RecordingTranslationProvider("second") { TranslateResult(emptyList()) }
val error = assertThrows(IllegalStateException::class.java) {
coordinator(listOf(first, second)).translate(request)
}
assertEquals("all translation providers failed", error.message)
assertNull(error.cause)
assertEquals(1, first.requests.size)
assertEquals(1, second.requests.size)
}
@Test
@DisplayName("빈 목록과 공백과 동일 언어와 미지원 언어는 공급자 호출 전에 거부한다")
fun shouldRejectInvalidRequestsBeforeCallingProviders() {
val provider = RecordingTranslationProvider("first")
val coordinator = coordinator(listOf(provider))
listOf(
request.copy(texts = emptyList()),
request.copy(texts = listOf("source", " \t")),
request.copy(targetLanguage = "KO"),
request.copy(sourceLanguage = "fr"),
request.copy(targetLanguage = "fr")
).forEach { invalid ->
val error = assertThrows(IllegalArgumentException::class.java) { coordinator.translate(invalid) }
assertEquals("invalid translation request", error.message)
assertNull(error.cause)
}
assertEquals(emptyList<TranslateRequest>(), provider.requests)
}
@Test
@DisplayName("모든 설정 공급자 캐시를 호출보다 먼저 조회하고 순서상 첫 유효 캐시를 선택한다")
fun shouldFindSecondaryCacheAndPreferConfiguredFirstValidCache() {
val first = RecordingTranslationProvider("first")
val second = RecordingTranslationProvider("second")
val coordinator = coordinator(listOf(first, second))
val secondary = memory("second", "cached secondary")
givenMemory("second", secondary)
assertSame(secondary, coordinator.findCached("hash", "ko", "en"))
givenMemory("first", memory("first", " "))
assertSame(secondary, coordinator.findCached("hash", "ko", "en"))
val primary = memory("first", "cached primary")
givenMemory("first", primary)
assertSame(primary, coordinator.findCached("hash", "ko", "en"))
assertEquals(emptyList<TranslateRequest>(), first.requests + second.requests)
}
@Test
@DisplayName("캐시 저장소 오류는 공급자 실패로 숨기지 않는다")
fun shouldPropagateCacheRepositoryFailure() {
val provider = RecordingTranslationProvider("first")
val failure = IllegalStateException("database unavailable")
Mockito.`when`(
repository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
"hash",
"ko",
"en",
"first",
"v1"
)
).thenThrow(failure)
assertSame(
failure,
assertThrows(IllegalStateException::class.java) { coordinator(listOf(provider)).findCached("hash", "ko", "en") }
)
assertEquals(emptyList<TranslateRequest>(), provider.requests)
}
private fun coordinator(
providers: List<TranslationProvider>,
order: List<String> = providers.map { it.providerName }
) = TranslationCoordinator(providers, LanguageProviderProperties(translationOrder = order.joinToString(",")), repository)
private fun memory(provider: String, text: String) = TranslationMemory(
"hash",
"source",
"ko",
"en",
text,
provider,
"$provider-v1"
)
private fun givenMemory(provider: String, memory: TranslationMemory) {
Mockito.`when`(
repository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
"hash",
"ko",
"en",
provider,
"v1"
)
).thenReturn(memory)
}
}
class RecordingTranslationProvider(
override val providerName: String,
private val response: (TranslateRequest) -> TranslateResult = { request ->
TranslateResult(request.texts.indices.map { "$providerName:$it" })
}
) : TranslationProvider {
override val providerVersion = "$providerName-v1"
val requests = mutableListOf<TranslateRequest>()
override fun translate(request: TranslateRequest): TranslateResult {
requests.add(request)
return response(request)
}
}