feat(i18n): AWS 언어 감지와 분할 번역 공급자를 추가한다
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
+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:
|
||||
credentials:
|
||||
accessKey: ""
|
||||
secretKey: ""
|
||||
accessKey: "test-access-key"
|
||||
secretKey: "test-secret-key"
|
||||
s3:
|
||||
contentBucket: ""
|
||||
bucket: ""
|
||||
|
||||
Reference in New Issue
Block a user