feat(i18n): AWS 언어 감지와 분할 번역 공급자를 추가한다
This commit is contained in:
@@ -16,6 +16,8 @@ plugins {
|
|||||||
group = "kr.co.vividnext"
|
group = "kr.co.vividnext"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
val querydslVersion = "5.0.0"
|
val querydslVersion = "5.0.0"
|
||||||
|
extra["httpclient5.version"] = "5.6.4"
|
||||||
|
extra["httpcore5.version"] = "5.4.3"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -56,6 +58,10 @@ dependencies {
|
|||||||
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
|
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
|
||||||
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
|
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
|
||||||
|
|
||||||
|
implementation(platform("software.amazon.awssdk:bom:2.57.11"))
|
||||||
|
implementation("software.amazon.awssdk:comprehend")
|
||||||
|
implementation("software.amazon.awssdk:translate")
|
||||||
|
|
||||||
// bootpay
|
// bootpay
|
||||||
implementation("io.github.bootpay:backend:2.2.1")
|
implementation("io.github.bootpay:backend:2.2.1")
|
||||||
|
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import software.amazon.awssdk.services.comprehend.ComprehendClient
|
||||||
|
import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageRequest
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AwsLanguageDetectionProvider(private val client: ComprehendClient) : LanguageDetectionProvider {
|
||||||
|
override val providerId = LanguageProviderId.AWS
|
||||||
|
|
||||||
|
override fun detect(query: String): String? {
|
||||||
|
if (query.isBlank()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// shortcut: 감지는 앞 100000 UTF-8 바이트만 판정하므로 전체 본문과 결과가 다를 수 있음, 전체 본문 기준 품질이 요구되면 별도 감지 정책 승인 후 업그레이드
|
||||||
|
val text = takeTextPrefixByCodePoint(query, MAX_REQUEST_TEXT_BYTES, ::utf8CodePointSize)
|
||||||
|
val request = DetectDominantLanguageRequest.builder().text(text).build()
|
||||||
|
client.detectDominantLanguage(request).languages().firstOrNull()?.languageCode()?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_REQUEST_TEXT_BYTES = 100000
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
|
||||||
|
import software.amazon.awssdk.regions.Region
|
||||||
|
import software.amazon.awssdk.services.comprehend.ComprehendClient
|
||||||
|
import software.amazon.awssdk.services.translate.TranslateClient
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class AwsLanguageProviderConfiguration(
|
||||||
|
@Value("\${cloud.aws.credentials.access-key}") accessKey: String,
|
||||||
|
@Value("\${cloud.aws.credentials.secret-key}") secretKey: String,
|
||||||
|
@Value("\${cloud.aws.region.static}") region: String
|
||||||
|
) {
|
||||||
|
private val credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))
|
||||||
|
private val region = Region.of(region)
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "close")
|
||||||
|
fun comprehendClient(): ComprehendClient = ComprehendClient.builder()
|
||||||
|
.credentialsProvider(credentialsProvider)
|
||||||
|
.region(region)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "close")
|
||||||
|
fun translateClient(): TranslateClient = TranslateClient.builder()
|
||||||
|
.credentialsProvider(credentialsProvider)
|
||||||
|
.region(region)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import software.amazon.awssdk.services.translate.TranslateClient
|
||||||
|
import software.amazon.awssdk.services.translate.model.TranslateTextRequest
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AwsTranslationService(private val client: TranslateClient) : TranslationProvider {
|
||||||
|
override val providerId = LanguageProviderId.AWS
|
||||||
|
override val providerVersion = "translate-2017-07-01"
|
||||||
|
|
||||||
|
override fun translate(request: TranslateRequest): TranslateResult {
|
||||||
|
if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) {
|
||||||
|
return TranslateResult(emptyList())
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!TranslationLanguages.isSupported(request.sourceLanguage) ||
|
||||||
|
!TranslationLanguages.isSupported(request.targetLanguage)
|
||||||
|
) {
|
||||||
|
return TranslateResult(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val translatedTexts = mutableListOf<String>()
|
||||||
|
try {
|
||||||
|
for (text in request.texts) {
|
||||||
|
val translatedText = StringBuilder()
|
||||||
|
for (chunk in splitTextByCodePoint(text, MAX_REQUEST_TEXT_BYTES, ::utf8CodePointSize)) {
|
||||||
|
val sdkRequest = TranslateTextRequest.builder()
|
||||||
|
.sourceLanguageCode(request.sourceLanguage)
|
||||||
|
.targetLanguageCode(request.targetLanguage)
|
||||||
|
.text(chunk)
|
||||||
|
.build()
|
||||||
|
val translated = client.translateText(sdkRequest)?.translatedText()
|
||||||
|
if (translated.isNullOrBlank()) return TranslateResult(emptyList())
|
||||||
|
translatedText.append(translated)
|
||||||
|
}
|
||||||
|
translatedTexts.add(translatedText.toString())
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return TranslateResult(emptyList())
|
||||||
|
}
|
||||||
|
return TranslateResult(translatedTexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_REQUEST_TEXT_BYTES = 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.Arguments
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||||
|
import software.amazon.awssdk.core.exception.SdkClientException
|
||||||
|
import software.amazon.awssdk.services.comprehend.ComprehendClient
|
||||||
|
import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageRequest
|
||||||
|
import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageResponse
|
||||||
|
import software.amazon.awssdk.services.comprehend.model.DominantLanguage
|
||||||
|
import java.util.stream.Stream
|
||||||
|
|
||||||
|
@SpringBootTest(classes = [AwsLanguageDetectionProvider::class], webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ExtendWith(OutputCaptureExtension::class)
|
||||||
|
class AwsLanguageDetectionProviderTest @Autowired constructor(
|
||||||
|
private val provider: AwsLanguageDetectionProvider
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var client: ComprehendClient
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["hi", " 안녕\t\n", "\uD83D\uDE00"])
|
||||||
|
@DisplayName("짧은 원문도 공백 변경 없이 감지하고 첫 후보를 반환한다")
|
||||||
|
fun shouldSendShortTextUnchangedAndUseFirstCandidate(query: String) {
|
||||||
|
respond(language("ja", 0.1f), language("en", 0.9f))
|
||||||
|
|
||||||
|
assertEquals("ja", provider.detect(query))
|
||||||
|
assertEquals(LanguageProviderId.AWS, provider.providerId)
|
||||||
|
assertRequestedText(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["", " ", "\t\n"])
|
||||||
|
@DisplayName("빈 감지 입력과 공백은 SDK를 호출하지 않는다")
|
||||||
|
fun shouldSkipBlankText(query: String) {
|
||||||
|
assertNull(provider.detect(query))
|
||||||
|
Mockito.verifyNoInteractions(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("prefixCases")
|
||||||
|
@DisplayName("감지 입력을 100000 UTF-8 바이트 이내의 정확한 code point prefix로 제한한다")
|
||||||
|
fun shouldSendExactCodePointPrefix(query: String, expected: String) {
|
||||||
|
respond(language("ko", 0.8f))
|
||||||
|
|
||||||
|
assertEquals("ko", provider.detect(query))
|
||||||
|
assertRequestedText(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("낮은 confidence의 미지원 첫 언어 코드도 그대로 반환한다")
|
||||||
|
fun shouldAcceptUnsupportedFirstLanguageWithoutConfidenceThreshold() {
|
||||||
|
respond(language("fr", 0.001f), language("en", 0.999f))
|
||||||
|
|
||||||
|
assertEquals("fr", provider.detect("bonjour"))
|
||||||
|
assertRequestedText("bonjour")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@NullAndEmptySource
|
||||||
|
@ValueSource(strings = [" \t\n"])
|
||||||
|
@DisplayName("첫 후보 코드가 없거나 공백이면 유효한 후속 후보도 선택하지 않는다")
|
||||||
|
fun shouldRejectInvalidFirstCandidateWithoutScanningLaterCandidates(code: String?) {
|
||||||
|
respond(language(code, 0.9f), language("en", 0.8f))
|
||||||
|
|
||||||
|
assertNull(provider.detect("source"))
|
||||||
|
assertRequestedText("source")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("감지 후보 목록이 없으면 null을 반환한다")
|
||||||
|
fun shouldReturnNullForMissingCandidates() {
|
||||||
|
respond()
|
||||||
|
|
||||||
|
assertNull(provider.detect("source"))
|
||||||
|
assertRequestedText("source")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SDK 실패는 원문과 오류를 로그에 노출하지 않고 null로 변환한다")
|
||||||
|
fun shouldReturnNullWithoutLeakingSdkFailure(output: CapturedOutput) {
|
||||||
|
Mockito.`when`(client.detectDominantLanguage(Mockito.any(DetectDominantLanguageRequest::class.java)))
|
||||||
|
.thenThrow(SdkClientException.create("sensitive-sdk-marker"))
|
||||||
|
|
||||||
|
assertNull(provider.detect("sensitive-source-marker"))
|
||||||
|
assertRequestedText("sensitive-source-marker")
|
||||||
|
assertFalse(output.all.contains("sensitive-sdk-marker"))
|
||||||
|
assertFalse(output.all.contains("sensitive-source-marker"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun respond(vararg languages: DominantLanguage) {
|
||||||
|
Mockito.`when`(client.detectDominantLanguage(Mockito.any(DetectDominantLanguageRequest::class.java)))
|
||||||
|
.thenReturn(DetectDominantLanguageResponse.builder().languages(*languages).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun language(code: String?, score: Float): DominantLanguage {
|
||||||
|
return DominantLanguage.builder().languageCode(code).score(score).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertRequestedText(expected: String) {
|
||||||
|
val captor = ArgumentCaptor.forClass(DetectDominantLanguageRequest::class.java)
|
||||||
|
Mockito.verify(client).detectDominantLanguage(captor.capture())
|
||||||
|
assertEquals(expected, captor.value.text())
|
||||||
|
Mockito.verifyNoMoreInteractions(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun prefixCases(): Stream<Arguments> = Stream.of(
|
||||||
|
Arguments.of("a".repeat(100000), "a".repeat(100000)),
|
||||||
|
Arguments.of("가".repeat(33333) + "x", "가".repeat(33333) + "x"),
|
||||||
|
Arguments.of("\uD83D\uDE00".repeat(25000), "\uD83D\uDE00".repeat(25000)),
|
||||||
|
Arguments.of("a".repeat(100000) + "excluded-tail", "a".repeat(100000)),
|
||||||
|
Arguments.of("a".repeat(99999) + "가tail", "a".repeat(99999)),
|
||||||
|
Arguments.of("a".repeat(99998) + "\uD83D\uDE00tail", "a".repeat(99998)),
|
||||||
|
Arguments.of("a".repeat(99996) + "\uD83D\uDE00tail", "a".repeat(99996) + "\uD83D\uDE00")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
|
||||||
|
import software.amazon.awssdk.awscore.AwsClient
|
||||||
|
import software.amazon.awssdk.core.client.config.SdkClientConfiguration
|
||||||
|
import software.amazon.awssdk.core.client.config.SdkClientOption
|
||||||
|
import software.amazon.awssdk.regions.Region
|
||||||
|
import software.amazon.awssdk.services.comprehend.ComprehendClient
|
||||||
|
import software.amazon.awssdk.services.translate.TranslateClient
|
||||||
|
|
||||||
|
class AwsLanguageProviderConfigurationTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("공용 테스트 YAML의 합성 키와 서울 리전으로 실제 AWS clients와 providers를 생성한다")
|
||||||
|
fun shouldLoadRealClientsAndProvidersFromSharedTestConfiguration() {
|
||||||
|
ApplicationContextRunner()
|
||||||
|
.withInitializer(ConfigDataApplicationContextInitializer())
|
||||||
|
.withUserConfiguration(
|
||||||
|
AwsLanguageProviderConfiguration::class.java,
|
||||||
|
AwsLanguageDetectionProvider::class.java,
|
||||||
|
AwsTranslationService::class.java
|
||||||
|
)
|
||||||
|
.run { context ->
|
||||||
|
assertNull(context.startupFailure)
|
||||||
|
assertEquals(1, context.getBeansOfType(ComprehendClient::class.java).size)
|
||||||
|
assertEquals(1, context.getBeansOfType(TranslateClient::class.java).size)
|
||||||
|
assertEquals(1, context.getBeansOfType(LanguageDetectionProvider::class.java).size)
|
||||||
|
assertEquals(1, context.getBeansOfType(TranslationProvider::class.java).size)
|
||||||
|
assertEquals(LanguageProviderId.AWS, context.getBean(AwsLanguageDetectionProvider::class.java).providerId)
|
||||||
|
assertEquals(LanguageProviderId.AWS, context.getBean(AwsTranslationService::class.java).providerId)
|
||||||
|
val clients = listOf(
|
||||||
|
context.getBean(ComprehendClient::class.java),
|
||||||
|
context.getBean(TranslateClient::class.java)
|
||||||
|
)
|
||||||
|
clients.forEach { client ->
|
||||||
|
val configuration = client.serviceClientConfiguration()
|
||||||
|
assertEquals(Region.AP_NORTHEAST_2, configuration.region())
|
||||||
|
assertTrue(configuration.credentialsProvider() is StaticCredentialsProvider)
|
||||||
|
val credentials = (configuration.credentialsProvider() as StaticCredentialsProvider).resolveCredentials()
|
||||||
|
assertEquals("test-access-key", credentials.accessKeyId())
|
||||||
|
assertEquals("test-secret-key", credentials.secretAccessKey())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"'',synthetic-secret-key,Access key ID cannot be blank.",
|
||||||
|
"synthetic-access-key,'',Secret access key cannot be blank."
|
||||||
|
)
|
||||||
|
@DisplayName("운영 AWS 설정은 access key 또는 secret key가 비어 있으면 시작을 거부한다")
|
||||||
|
fun shouldRejectBlankCredentialsAtStartup(accessKey: String, secretKey: String, expectedMessage: String) {
|
||||||
|
ApplicationContextRunner()
|
||||||
|
.withUserConfiguration(AwsLanguageProviderConfiguration::class.java)
|
||||||
|
.withPropertyValues(
|
||||||
|
"cloud.aws.credentials.access-key=$accessKey",
|
||||||
|
"cloud.aws.credentials.secret-key=$secretKey",
|
||||||
|
"cloud.aws.region.static=ap-northeast-2"
|
||||||
|
)
|
||||||
|
.run { context ->
|
||||||
|
assertNotNull(context.startupFailure)
|
||||||
|
val rootCause = generateSequence(context.startupFailure) { it.cause }.last()
|
||||||
|
assertTrue(rootCause is NullPointerException)
|
||||||
|
assertEquals(expectedMessage, rootCause.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["ap-northeast-2", "eu-west-1"])
|
||||||
|
@DisplayName("기존 명시 설정으로 실제 동기 SDK clients를 생성하고 Spring 종료 시 둘 다 닫는다")
|
||||||
|
fun shouldConfigureRealClientsAndCloseThemAtShutdown(region: String) {
|
||||||
|
val poolClosedChecks = mutableListOf<() -> Boolean>()
|
||||||
|
ApplicationContextRunner()
|
||||||
|
.withUserConfiguration(AwsLanguageProviderConfiguration::class.java)
|
||||||
|
.withPropertyValues(
|
||||||
|
"cloud.aws.credentials.access-key=synthetic-access-key",
|
||||||
|
"cloud.aws.credentials.secret-key=synthetic-secret-key",
|
||||||
|
"cloud.aws.region.static=$region"
|
||||||
|
)
|
||||||
|
.run { context ->
|
||||||
|
assertNull(context.startupFailure)
|
||||||
|
assertEquals(1, context.getBeansOfType(ComprehendClient::class.java).size)
|
||||||
|
assertEquals(1, context.getBeansOfType(TranslateClient::class.java).size)
|
||||||
|
val clients = listOf(
|
||||||
|
context.getBean(ComprehendClient::class.java),
|
||||||
|
context.getBean(TranslateClient::class.java)
|
||||||
|
)
|
||||||
|
clients.forEach { client ->
|
||||||
|
val configuration = client.serviceClientConfiguration()
|
||||||
|
assertEquals(Region.of(region), configuration.region())
|
||||||
|
assertTrue(configuration.credentialsProvider() is StaticCredentialsProvider)
|
||||||
|
val credentials = (configuration.credentialsProvider() as StaticCredentialsProvider).resolveCredentials()
|
||||||
|
assertEquals("synthetic-access-key", credentials.accessKeyId())
|
||||||
|
assertEquals("synthetic-secret-key", credentials.secretAccessKey())
|
||||||
|
val isClosed = connectionPoolClosedCheck(client)
|
||||||
|
assertFalse(isClosed())
|
||||||
|
poolClosedChecks.add(isClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(2, poolClosedChecks.size)
|
||||||
|
poolClosedChecks.forEach { assertTrue(it()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectionPoolClosedCheck(client: AwsClient): () -> Boolean {
|
||||||
|
val configuration = ReflectionTestUtils.getField(client, "clientConfiguration") as SdkClientConfiguration
|
||||||
|
val httpClient = configuration.option(SdkClientOption.SYNC_HTTP_CLIENT)
|
||||||
|
val apacheClient = requireNotNull(ReflectionTestUtils.getField(httpClient, "httpClient"))
|
||||||
|
val connectionManager = requireNotNull(
|
||||||
|
ReflectionTestUtils.invokeMethod<Any>(apacheClient, "getHttpClientConnectionManager")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
requireNotNull(ReflectionTestUtils.invokeMethod<Boolean>(connectionManager, "isClosed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||||
|
import software.amazon.awssdk.core.exception.SdkClientException
|
||||||
|
import software.amazon.awssdk.services.translate.TranslateClient
|
||||||
|
import software.amazon.awssdk.services.translate.model.TranslateTextRequest
|
||||||
|
import software.amazon.awssdk.services.translate.model.TranslateTextResponse
|
||||||
|
|
||||||
|
@SpringBootTest(classes = [AwsTranslationService::class], webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ExtendWith(OutputCaptureExtension::class)
|
||||||
|
class AwsTranslationServiceTest @Autowired constructor(
|
||||||
|
private val service: AwsTranslationService
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var client: TranslateClient
|
||||||
|
private val requests = mutableListOf<TranslateTextRequest>()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
requests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(ints = [12, 10000])
|
||||||
|
@DisplayName("10000 UTF-8 바이트 이하 원문은 한 번 요청하고 AWS 결과를 반환한다")
|
||||||
|
fun shouldTranslateTextWithinByteLimit(length: Int) {
|
||||||
|
respondWith { response("translated") }
|
||||||
|
val source = "a".repeat(length)
|
||||||
|
|
||||||
|
assertEquals(listOf("translated"), translate(source).translatedText)
|
||||||
|
assertEquals(LanguageProviderId.AWS, service.providerId)
|
||||||
|
assertEquals("translate-2017-07-01", service.providerVersion)
|
||||||
|
assertRequests(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("10001바이트 원문을 순서대로 나누고 구분자 없이 결합한다")
|
||||||
|
fun shouldSplitTextOverByteLimitAndJoinWithoutSeparator() {
|
||||||
|
val first = "a".repeat(10000)
|
||||||
|
respondWith { response(if (it.text() == first) "first " else "tail") }
|
||||||
|
|
||||||
|
assertEquals(listOf("first tail"), translate(first + "b").translatedText)
|
||||||
|
assertRequests(first, "b")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("한글 code point와 공백을 UTF-8 한도에 맞춰 손실 없이 분할한다")
|
||||||
|
fun shouldPreserveKoreanAndWhitespaceAcrossChunks() {
|
||||||
|
val first = "가".repeat(3333) + "\n"
|
||||||
|
val second = " 나\t"
|
||||||
|
respondWith { response(if (it.text() == first) "first" else "second") }
|
||||||
|
|
||||||
|
assertEquals(listOf("firstsecond"), translate(first + second).translatedText)
|
||||||
|
assertRequests(first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("경계에 걸친 보조 평면 문자를 다음 조각에 함께 보존한다")
|
||||||
|
fun shouldKeepSupplementaryCodePointTogetherAtBoundary() {
|
||||||
|
val first = "a".repeat(9998)
|
||||||
|
val second = "\uD83D\uDE00b"
|
||||||
|
respondWith { response(if (it.text() == first) "first" else "second") }
|
||||||
|
|
||||||
|
assertEquals(listOf("firstsecond"), translate(first + second).translatedText)
|
||||||
|
assertRequests(first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("정확히 10000바이트인 보조 평면 원문은 분할하지 않는다")
|
||||||
|
fun shouldKeepExactSupplementaryByteLimitInOneRequest() {
|
||||||
|
val source = "\uD83D\uDE00".repeat(2500)
|
||||||
|
respondWith { response("translated") }
|
||||||
|
|
||||||
|
assertEquals(listOf("translated"), translate(source).translatedText)
|
||||||
|
assertRequests(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("여러 원문의 조각을 순차 호출하고 입력 순서대로 원문당 결과 하나를 반환한다")
|
||||||
|
fun shouldReturnOneJoinedOutputPerOriginalInOrder() {
|
||||||
|
val first = "a".repeat(10000)
|
||||||
|
val second = "b".repeat(10000)
|
||||||
|
val translated = listOf("first-", "tail", "second-", "tail")
|
||||||
|
respondWith { response(translated[requests.size - 1]) }
|
||||||
|
|
||||||
|
assertEquals(listOf("first-tail", "second-tail"), translate(first + "a", second + "b").translatedText)
|
||||||
|
assertRequests(first, "a", second, "b")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource("ko,en", "en,ja", "ja,ko")
|
||||||
|
@DisplayName("기존 지원 언어의 source와 target을 SDK 요청에 명시한다")
|
||||||
|
fun shouldSendExplicitSupportedLanguages(source: String, target: String) {
|
||||||
|
respondWith { response("translated") }
|
||||||
|
|
||||||
|
assertEquals(listOf("translated"), service.translate(TranslateRequest(listOf("source"), source, target)).translatedText)
|
||||||
|
assertEquals(1, requests.size)
|
||||||
|
assertEquals("source", requests.single().text())
|
||||||
|
assertEquals(source, requests.single().sourceLanguageCode())
|
||||||
|
assertEquals(target, requests.single().targetLanguageCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"middle,exception", "middle,null-response", "middle,missing-text", "middle,empty", "middle,blank",
|
||||||
|
"later,exception", "later,null-response", "later,missing-text", "later,empty", "later,blank"
|
||||||
|
)
|
||||||
|
@DisplayName("중간 조각이나 다음 원문의 실패는 앞선 성공을 폐기하고 후속 호출을 중단한다")
|
||||||
|
fun shouldDiscardEntireRequestAndStopAfterAnyFailure(position: String, failure: String, output: CapturedOutput) {
|
||||||
|
val first = "a".repeat(10000)
|
||||||
|
val broken = "b".repeat(10000)
|
||||||
|
val failingText = if (position == "middle") broken else "sensitive-source-marker"
|
||||||
|
respondWith {
|
||||||
|
if (it.text() != failingText) {
|
||||||
|
response("partial success")
|
||||||
|
} else {
|
||||||
|
when (failure) {
|
||||||
|
"exception" -> throw SdkClientException.create("sensitive-sdk-marker")
|
||||||
|
"null-response" -> null
|
||||||
|
"missing-text" -> response(null)
|
||||||
|
"empty" -> response("")
|
||||||
|
else -> response(" \t\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val texts = if (position == "middle") {
|
||||||
|
listOf("earlier", first + broken + "c", "later")
|
||||||
|
} else {
|
||||||
|
listOf("earlier", failingText, "later")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(emptyList<String>(), service.translate(TranslateRequest(texts, "ko", "en")).translatedText)
|
||||||
|
if (position == "middle") assertRequests("earlier", first, broken) else assertRequests("earlier", failingText)
|
||||||
|
assertFalse(output.all.contains("sensitive-sdk-marker"))
|
||||||
|
assertFalse(output.all.contains("sensitive-source-marker"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["", " \t\n"])
|
||||||
|
@DisplayName("빈 raw 문자열과 공백도 원문 그대로 SDK에 전달한다")
|
||||||
|
fun shouldPreserveEmptyAndWhitespaceRawText(source: String) {
|
||||||
|
respondWith { response("translated") }
|
||||||
|
|
||||||
|
assertEquals(listOf("translated"), translate(source).translatedText)
|
||||||
|
assertRequests(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("빈 원문 목록이면 SDK를 호출하지 않는다")
|
||||||
|
fun shouldSkipEmptyList() {
|
||||||
|
assertEquals(emptyList<String>(), translate().translatedText)
|
||||||
|
Mockito.verifyNoInteractions(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("같은 언어이면 SDK를 호출하지 않는다")
|
||||||
|
fun shouldSkipSameLanguage() {
|
||||||
|
assertEquals(emptyList<String>(), service.translate(TranslateRequest(listOf("source"), "ko", "ko")).translatedText)
|
||||||
|
Mockito.verifyNoInteractions(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource("fr,en", "ko,fr", "auto,en")
|
||||||
|
@DisplayName("미지원 source 또는 target이면 SDK를 호출하지 않는다")
|
||||||
|
fun shouldSkipUnsupportedLanguage(source: String, target: String) {
|
||||||
|
assertEquals(emptyList<String>(), service.translate(TranslateRequest(listOf("source"), source, target)).translatedText)
|
||||||
|
Mockito.verifyNoInteractions(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translate(vararg texts: String): TranslateResult {
|
||||||
|
return service.translate(TranslateRequest(texts.toList(), "ko", "en"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun respondWith(handler: (TranslateTextRequest) -> TranslateTextResponse?) {
|
||||||
|
Mockito.`when`(client.translateText(Mockito.any(TranslateTextRequest::class.java))).thenAnswer {
|
||||||
|
val request = it.getArgument<TranslateTextRequest>(0)
|
||||||
|
requests.add(request)
|
||||||
|
handler(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun response(text: String?): TranslateTextResponse {
|
||||||
|
return TranslateTextResponse.builder().sourceLanguageCode("ko").targetLanguageCode("en").translatedText(text).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertRequests(vararg texts: String) {
|
||||||
|
assertEquals(texts.toList(), requests.map { it.text() })
|
||||||
|
requests.forEach {
|
||||||
|
assertEquals("ko", it.sourceLanguageCode())
|
||||||
|
assertEquals("en", it.targetLanguageCode())
|
||||||
|
}
|
||||||
|
Mockito.verify(client, Mockito.times(texts.size)).translateText(Mockito.any(TranslateTextRequest::class.java))
|
||||||
|
Mockito.verifyNoMoreInteractions(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,8 @@ cloud:
|
|||||||
|
|
||||||
aws:
|
aws:
|
||||||
credentials:
|
credentials:
|
||||||
accessKey: ""
|
accessKey: "test-access-key"
|
||||||
secretKey: ""
|
secretKey: "test-secret-key"
|
||||||
s3:
|
s3:
|
||||||
contentBucket: ""
|
contentBucket: ""
|
||||||
bucket: ""
|
bucket: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user