From 7cac09e39c95a1af59411b70df5381297e81a395 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 10 Nov 2026 10:23:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20AWS=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=EC=99=80=20=EB=B6=84=ED=95=A0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EA=B3=B5=EA=B8=89=EC=9E=90=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 6 + .../AwsLanguageDetectionProvider.kt | 27 +++ .../AwsLanguageProviderConfiguration.kt | 32 +++ .../i18n/translation/AwsTranslationService.kt | 48 ++++ .../AwsLanguageDetectionProviderTest.kt | 134 +++++++++++ .../AwsLanguageProviderConfigurationTest.kt | 127 +++++++++++ .../translation/AwsTranslationServiceTest.kt | 209 ++++++++++++++++++ src/test/resources/application.yml | 4 +- 8 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 5bae987e..0552043c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,8 @@ plugins { group = "kr.co.vividnext" version = "0.0.1-SNAPSHOT" val querydslVersion = "5.0.0" +extra["httpclient5.version"] = "5.6.4" +extra["httpcore5.version"] = "5.4.3" java { 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-cloudfront:1.12.380") + implementation(platform("software.amazon.awssdk:bom:2.57.11")) + implementation("software.amazon.awssdk:comprehend") + implementation("software.amazon.awssdk:translate") + // bootpay implementation("io.github.bootpay:backend:2.2.1") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt new file mode 100644 index 00000000..3cd81f59 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt @@ -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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt new file mode 100644 index 00000000..d6359905 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt @@ -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() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt new file mode 100644 index 00000000..33e8dd57 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt @@ -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() + 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 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt new file mode 100644 index 00000000..90c0f00a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt @@ -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 = 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") + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt new file mode 100644 index 00000000..5f1814d7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt @@ -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(apacheClient, "getHttpClientConnectionManager") + ) + return { + requireNotNull(ReflectionTestUtils.invokeMethod(connectionManager, "isClosed")) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt new file mode 100644 index 00000000..f7cc3d28 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt @@ -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() + + @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(), 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(), translate().translatedText) + Mockito.verifyNoInteractions(client) + } + + @Test + @DisplayName("같은 언어이면 SDK를 호출하지 않는다") + fun shouldSkipSameLanguage() { + assertEquals(emptyList(), 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(), 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(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) + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 2992752c..8fe31993 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -78,8 +78,8 @@ cloud: aws: credentials: - accessKey: "" - secretKey: "" + accessKey: "test-access-key" + secretKey: "test-secret-key" s3: contentBucket: "" bucket: ""