diff --git a/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1.kt b/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1.kt index 6c0e8e7..212e099 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1.kt @@ -6,7 +6,6 @@ import kr.galaxyhub.sc.api.v1.translation.dto.TranslationRequest import kr.galaxyhub.sc.common.support.toUri import kr.galaxyhub.sc.translation.application.TranslationCommandService import kr.galaxyhub.sc.translation.application.TranslationQueryService -import kr.galaxyhub.sc.translation.application.dto.TranslationCommand import kr.galaxyhub.sc.translation.application.dto.TranslationResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -28,7 +27,7 @@ class TranslationControllerV1( @PathVariable newsId: UUID, @RequestBody request: TranslationRequest, ): ResponseEntity> { - val command = TranslationCommand(newsId, request.destinationLanguage) + val command = request.toCommand(newsId) val translateProgressionId = translationCommandService.translate(command) return ResponseEntity.created("/api/v1/translation/${translateProgressionId}".toUri()) .body(ApiResponse.success(translateProgressionId)) diff --git a/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/dto/TranslationRequest.kt b/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/dto/TranslationRequest.kt index 4a09d2e..1db4965 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/dto/TranslationRequest.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/api/v1/translation/dto/TranslationRequest.kt @@ -1,7 +1,20 @@ package kr.galaxyhub.sc.api.v1.translation.dto +import java.util.UUID import kr.galaxyhub.sc.news.domain.Language +import kr.galaxyhub.sc.translation.application.dto.TranslationCommand +import kr.galaxyhub.sc.translation.domain.TranslatorProvider data class TranslationRequest( - val destinationLanguage: Language -) + val sourceLanguage: Language, + val targetLanguage: Language, + val translatorProvider: TranslatorProvider, +) { + + fun toCommand(newsId: UUID) = TranslationCommand( + newsId = newsId, + sourceLanguage = sourceLanguage, + targetLanguage = targetLanguage, + translatorProvider = translatorProvider + ) +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsAppendContentEventListener.kt b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsAppendContentEventListener.kt new file mode 100644 index 0000000..6bb6c24 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsAppendContentEventListener.kt @@ -0,0 +1,34 @@ +package kr.galaxyhub.sc.news.application + +import io.github.oshai.kotlinlogging.KotlinLogging +import kr.galaxyhub.sc.news.application.dto.NewsAppendContentEvent +import kr.galaxyhub.sc.news.domain.NewsRepository +import kr.galaxyhub.sc.news.domain.getOrThrow +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +private val log = KotlinLogging.logger {} + +/** + * TranslationCommandService.translate 메시드 호출 시 News가 이미 생성되어 있으므로 예외 발생 가능성은 없음 + * 하지만 혹시 모를 상황에 error 로그 남김 + */ +@Component +@Transactional +class NewsAppendContentEventListener( + private val newsRepository: NewsRepository +) { + + @EventListener + fun appendContent(event: NewsAppendContentEvent) { + runCatching { + newsRepository.getOrThrow(event.newsId) + }.onSuccess { + it.addContent(event.content) + }.onFailure { + log.error { "뉴스에 컨텐츠를 추가하는 중 예외가 발생했습니다. ${it.message}" } + } + } +} + diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsCommandService.kt b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsCommandService.kt index 9d0525b..ab108da 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsCommandService.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsCommandService.kt @@ -19,13 +19,13 @@ class NewsCommandService( fun create(command: NewsCreateCommand): UUID { val news = newsRepository.findByOriginId(command.originId) ?: newsRepository.save(command.toNews()) - val content = createContent(news, command) + val content = createContent(news.id, command) news.addContent(content) return news.id } - private fun createContent(news: News, command: NewsCreateCommand) = Content( - news = news, + private fun createContent(newsId: UUID, command: NewsCreateCommand) = Content( + newsId = newsId, language = command.language, content = command.content, newsInformation = NewsInformation( diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/application/dto/NewsAppendContentEvent.kt b/src/main/kotlin/kr/galaxyhub/sc/news/application/dto/NewsAppendContentEvent.kt new file mode 100644 index 0000000..b73f332 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/news/application/dto/NewsAppendContentEvent.kt @@ -0,0 +1,10 @@ +package kr.galaxyhub.sc.news.application.dto + +import java.util.UUID +import kr.galaxyhub.sc.news.domain.Content + +data class NewsAppendContentEvent( + val newsId: UUID, + val translateProgressionId: UUID, + val content: Content +) diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/domain/Content.kt b/src/main/kotlin/kr/galaxyhub/sc/news/domain/Content.kt index 1fa2507..e1ee3f3 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/domain/Content.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/domain/Content.kt @@ -5,16 +5,15 @@ import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated -import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne +import java.util.UUID @Entity class Content( - news: News, + newsId: UUID, newsInformation: NewsInformation, language: Language, content: String, @@ -24,9 +23,8 @@ class Content( @GeneratedValue(strategy = GenerationType.IDENTITY) val sequence: Long? = null - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "news_id", nullable = false) - var news: News = news + var newsId: UUID = newsId protected set @Embedded diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/domain/Language.kt b/src/main/kotlin/kr/galaxyhub/sc/news/domain/Language.kt index ae9dc0d..0fa2b8a 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/domain/Language.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/domain/Language.kt @@ -1,6 +1,9 @@ package kr.galaxyhub.sc.news.domain -enum class Language { - ENGLISH, - KOREAN, +enum class Language( + val shortName: String, +) { + + ENGLISH("EN"), + KOREAN("KO"), } diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/domain/News.kt b/src/main/kotlin/kr/galaxyhub/sc/news/domain/News.kt index 9b13eb2..1cd3ff3 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/domain/News.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/domain/News.kt @@ -49,7 +49,7 @@ class News( private val mutableSupportLanguages: EnumSet = EnumSet.noneOf(Language::class.java) val supportLanguages: Set get() = mutableSupportLanguages.toHashSet() - @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST], mappedBy = "news") + @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST], mappedBy = "newsId") private val contents: MutableList = mutableListOf() fun addContent(content: Content) { @@ -62,7 +62,7 @@ class News( } private fun validateAddContent(content: Content) { - validate(content.news != this) { "컨텐츠에 등록된 뉴스가 동일하지 않습니다." } + validate(content.newsId != id) { "컨텐츠에 등록된 뉴스가 동일하지 않습니다." } validate(mutableSupportLanguages.contains(content.language)) { "이미 해당 언어로 작성된 뉴스가 있습니다." } } @@ -70,4 +70,8 @@ class News( return contents.find { it.language == language } ?: throw NotFoundException("해당 언어의 컨텐츠가 존재하지 않습니다.") } + + fun isSupportLanguage(language: Language): Boolean { + return mutableSupportLanguages.contains(language) + } } diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/domain/NewsRepository.kt b/src/main/kotlin/kr/galaxyhub/sc/news/domain/NewsRepository.kt index 8c6f321..acd0d8a 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/domain/NewsRepository.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/domain/NewsRepository.kt @@ -9,6 +9,9 @@ import org.springframework.data.repository.query.Param fun NewsRepository.getByDetailByIdAndLanguage(id: UUID, language: Language) = findFetchByIdAndLanguage(id, language) ?: throw NotFoundException("식별자와 언어에 대한 뉴스를 찾을 수 없습니다. id=${id}, language=${language}") +fun NewsRepository.getOrThrow(id: UUID) = findById(id) + ?: throw NotFoundException("식별자에 대한 뉴스를 찾을 수 없습니다. id=${id}") + interface NewsRepository : Repository { fun save(news: News): News diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationAppendContentEventListener.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationAppendContentEventListener.kt new file mode 100644 index 0000000..691b3cc --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationAppendContentEventListener.kt @@ -0,0 +1,48 @@ +package kr.galaxyhub.sc.translation.application + +import io.github.oshai.kotlinlogging.KotlinLogging +import kr.galaxyhub.sc.news.application.dto.NewsAppendContentEvent +import kr.galaxyhub.sc.translation.application.dto.TranslatorFailureEvent +import kr.galaxyhub.sc.translation.domain.TranslationProgressionRepository +import kr.galaxyhub.sc.translation.domain.getOrThrow +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +private val log = KotlinLogging.logger {} + +/** + * TranslationCommandService.translate 메서드의 트랜잭션이 API 호출보다 늦게 끝나면 예외 발생 가능성 있음. + * 따라서 log를 error로 발생시킴 + * 해당 로그 파악하면 Retry 전략 구성할 것 + * 혹은 TranslationCommandService.translate에서 TransactionalEventListener를 받는곳에서 WebClient 실행 고려 + */ +@Component +@Transactional +class TranslationAppendContentEventListener( + private val translationProgressionRepository: TranslationProgressionRepository +) { + + @EventListener + fun changeTranslationProgressionComplete(event: NewsAppendContentEvent) { + runCatching { + translationProgressionRepository.getOrThrow(event.translateProgressionId) + }.onSuccess { + it.changeComplete() + }.onFailure { + log.error { "번역 진행 상황의 상태를 변경 중 예외가 발생했습니다. message=${it.message}" } + } + } + + @EventListener + fun changeTranslationProgressionFailure(event: TranslatorFailureEvent) { + runCatching { + translationProgressionRepository.getOrThrow(event.translateProgressionId) + }.onSuccess { + it.changeFailure(event.message) + }.onFailure { + log.error { "번역 진행 상황의 상태를 변경 중 예외가 발생했습니다. message=${it.message}" } + } + } +} + diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt index fb83197..79b247b 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt @@ -1,23 +1,48 @@ package kr.galaxyhub.sc.translation.application +import io.github.oshai.kotlinlogging.KotlinLogging import java.util.UUID +import kr.galaxyhub.sc.common.support.validate +import kr.galaxyhub.sc.news.application.dto.NewsAppendContentEvent +import kr.galaxyhub.sc.news.domain.NewsRepository +import kr.galaxyhub.sc.news.domain.getByDetailByIdAndLanguage import kr.galaxyhub.sc.translation.application.dto.TranslationCommand +import kr.galaxyhub.sc.translation.application.dto.TranslatorFailureEvent import kr.galaxyhub.sc.translation.domain.TranslateProgression import kr.galaxyhub.sc.translation.domain.TranslationProgressionRepository +import kr.galaxyhub.sc.translation.domain.TranslatorClients +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +private val log = KotlinLogging.logger {} + @Service @Transactional class TranslationCommandService( - private val translationProgressionRepository: TranslationProgressionRepository + private val translationProgressionRepository: TranslationProgressionRepository, + private val translatorClients: TranslatorClients, + private val newsRepository: NewsRepository, + private val eventPublisher: ApplicationEventPublisher, ) { fun translate(command: TranslationCommand): UUID { - val newsId = command.newsId - val destinationLanguage = command.destinationLanguage - val translateProgression = TranslateProgression(newsId, destinationLanguage) + val (newsId, sourceLanguage, targetLanguage, translatorProvider) = command + val news = newsRepository.getByDetailByIdAndLanguage(newsId, sourceLanguage) + validate(news.isSupportLanguage(targetLanguage)) { "이미 뉴스에 번역된 컨텐츠가 존재합니다. targetLanguage=$targetLanguage" } + val content = news.getContentByLanguage(sourceLanguage) + val translateProgression = TranslateProgression(newsId, sourceLanguage, targetLanguage, translatorProvider) translationProgressionRepository.save(translateProgression) + val translatorClient = translatorClients.getClient(translatorProvider) + translatorClient.requestTranslate(content, targetLanguage) + .doOnError { + log.warn { "뉴스 번역 요청이 실패하였습니다. newsId=${newsId}, translateProgressionId=${translateProgression.id}" } + eventPublisher.publishEvent(TranslatorFailureEvent(translateProgression.id, it.message)) + } + .subscribe { + log.info { "뉴스 번역 요청이 완료되었습니다. newsId=${newsId}, translateProgressionId=${translateProgression.id}" } + eventPublisher.publishEvent(NewsAppendContentEvent(newsId, translateProgression.id, it)) + } return translateProgression.id } } diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationCommand.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationCommand.kt index af31ba9..9010b40 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationCommand.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationCommand.kt @@ -2,8 +2,11 @@ package kr.galaxyhub.sc.translation.application.dto import java.util.UUID import kr.galaxyhub.sc.news.domain.Language +import kr.galaxyhub.sc.translation.domain.TranslatorProvider data class TranslationCommand( val newsId: UUID, - val destinationLanguage: Language + val sourceLanguage: Language, + val targetLanguage: Language, + val translatorProvider: TranslatorProvider, ) diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationResponse.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationResponse.kt index 3c8ed7e..20d646e 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationResponse.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslationResponse.kt @@ -1,12 +1,17 @@ package kr.galaxyhub.sc.translation.application.dto import java.util.UUID +import kr.galaxyhub.sc.news.domain.Language import kr.galaxyhub.sc.translation.domain.TranslateProgression import kr.galaxyhub.sc.translation.domain.TranslationStatus +import kr.galaxyhub.sc.translation.domain.TranslatorProvider data class TranslationResponse( val translateProgressionId: UUID, val targetNewsId: UUID, + val sourceLanguage: Language, + val targetLanguage: Language, + val translationProvider: TranslatorProvider, val translationStatus: TranslationStatus, val message: String? = null, ) { @@ -18,7 +23,10 @@ data class TranslationResponse( translateProgressionId = translationProgression.id, targetNewsId = translationProgression.newsId, translationStatus = translationProgression.translationStatus, - message = translationProgression.message + message = translationProgression.message, + sourceLanguage = translationProgression.sourceLanguage, + targetLanguage = translationProgression.targetLanguage, + translationProvider = translationProgression.translatorProvider ) } } diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslatorFailureEvent.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslatorFailureEvent.kt new file mode 100644 index 0000000..41e547f --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/dto/TranslatorFailureEvent.kt @@ -0,0 +1,8 @@ +package kr.galaxyhub.sc.translation.application.dto + +import java.util.UUID + +data class TranslatorFailureEvent( + val translateProgressionId: UUID, + val message: String? +) diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt new file mode 100644 index 0000000..3ae29df --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt @@ -0,0 +1,34 @@ +package kr.galaxyhub.sc.translation.config + +import kr.galaxyhub.sc.translation.domain.TranslatorClient +import kr.galaxyhub.sc.translation.domain.TranslatorClients +import kr.galaxyhub.sc.translation.infra.DeepLTranslatorClient +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.http.HttpHeaders +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class TranslatorClientConfig( + @Value("\${galaxyhub.translator.deepl-api-key}") private val deepLApiKey: String, + private val webClient: WebClient, +) { + + @Bean + @Profile("!test") + fun deepLTranslatorClient(): TranslatorClient { + return DeepLTranslatorClient( + webClient.mutate() + .baseUrl("https://api.deepl.com") + .defaultHeader(HttpHeaders.AUTHORIZATION, "DeepL-Auth-Key $deepLApiKey") + .build() + ) + } + + @Bean + fun translatorClients(translatorClients: List): TranslatorClients { + return TranslatorClients.from(translatorClients) + } +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgression.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgression.kt index d728802..19a2f6b 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgression.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgression.kt @@ -8,38 +8,73 @@ import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint import java.util.UUID import kr.galaxyhub.sc.common.domain.PrimaryKeyEntity +import kr.galaxyhub.sc.common.support.validate import kr.galaxyhub.sc.news.domain.Language @Entity @Table( + name = "translate_progression", uniqueConstraints = [ UniqueConstraint( name = "UNIQUE_NEWS_ID_AND_DESTINATION_LANGUAGE", columnNames = [ "news_id", - "destination_language" + "target_language" ] ) ] ) class TranslateProgression( newsId: UUID, - destinationLanguage: Language, + sourceLanguage: Language, + targetLanguage: Language, + translatorProvider: TranslatorProvider, ) : PrimaryKeyEntity() { + init { + validate(sourceLanguage == targetLanguage) { + "sourceLanguage와 targetLanguage는 같을 수 없습니다. language=$sourceLanguage" + } + } + @Column(name = "news_id", nullable = false, columnDefinition = "uuid") val newsId: UUID = newsId @Enumerated(EnumType.STRING) - @Column(name = "destination_language", nullable = false, columnDefinition = "varchar") - val destinationLanguage: Language = destinationLanguage + @Column(name = "source_language", nullable = false, columnDefinition = "varchar") + val sourceLanguage: Language = sourceLanguage + + @Enumerated(EnumType.STRING) + @Column(name = "target_language", nullable = false, columnDefinition = "varchar") + val targetLanguage: Language = targetLanguage @Enumerated(EnumType.STRING) @Column(name = "translation_status", nullable = false, columnDefinition = "varchar") var translationStatus: TranslationStatus = TranslationStatus.PROGRESS protected set + @Enumerated(EnumType.STRING) + @Column(name = "translator_provider", nullable = false, columnDefinition = "varchar") + val translatorProvider: TranslatorProvider = translatorProvider + @Column(name = "message") var message: String? = null protected set + + fun changeComplete() { + checkStatusProgress() + translationStatus = TranslationStatus.COMPLETE + } + + fun changeFailure(message: String?) { + checkStatusProgress() + translationStatus = TranslationStatus.FAILURE + this.message = message + } + + private fun checkStatusProgress() { + validate(translationStatus != TranslationStatus.PROGRESS) { + "translationStatus이 PROGRESS가 아닙니다. translationStatus=$translationStatus" + } + } } diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslationStatus.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslationStatus.kt index 9d5fed4..9a00591 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslationStatus.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslationStatus.kt @@ -3,5 +3,5 @@ package kr.galaxyhub.sc.translation.domain enum class TranslationStatus { PROGRESS, COMPLETE, - ERROR, + FAILURE, } diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorClient.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorClient.kt new file mode 100644 index 0000000..2270ab9 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorClient.kt @@ -0,0 +1,12 @@ +package kr.galaxyhub.sc.translation.domain + +import kr.galaxyhub.sc.news.domain.Content +import kr.galaxyhub.sc.news.domain.Language +import reactor.core.publisher.Mono + +interface TranslatorClient { + + fun requestTranslate(content: Content, targetLanguage: Language): Mono + + fun getProvider(): TranslatorProvider +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorClients.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorClients.kt new file mode 100644 index 0000000..b8ffb10 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorClients.kt @@ -0,0 +1,25 @@ +package kr.galaxyhub.sc.translation.domain + +import java.util.EnumMap +import kr.galaxyhub.sc.common.exception.BadRequestException + +class TranslatorClients( + private val translatorClients: Map, +) { + + fun getClient(translatorProvider: TranslatorProvider): TranslatorClient { + return translatorClients[translatorProvider] + ?: throw BadRequestException("해당 번역 서비스 제공자는 제공되지 않습니다. translatorProvider=$translatorProvider") + } + + companion object { + + fun from(translatorClients: List): TranslatorClients { + val translatorClientMap = EnumMap(TranslatorProvider::class.java) + translatorClients.forEach { + translatorClientMap[it.getProvider()] = it + } + return TranslatorClients(translatorClientMap) + } + } +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorProvider.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorProvider.kt new file mode 100644 index 0000000..91600c3 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/domain/TranslatorProvider.kt @@ -0,0 +1,6 @@ +package kr.galaxyhub.sc.translation.domain + +enum class TranslatorProvider { + LOCAL, + DEEPL, +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt new file mode 100644 index 0000000..246a26d --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt @@ -0,0 +1,122 @@ +package kr.galaxyhub.sc.translation.infra + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.UUID +import kr.galaxyhub.sc.common.exception.BadRequestException +import kr.galaxyhub.sc.common.exception.GalaxyhubException +import kr.galaxyhub.sc.common.exception.InternalServerError +import kr.galaxyhub.sc.news.domain.Content +import kr.galaxyhub.sc.news.domain.Language +import kr.galaxyhub.sc.news.domain.NewsInformation +import kr.galaxyhub.sc.translation.domain.TranslatorClient +import kr.galaxyhub.sc.translation.domain.TranslatorProvider +import org.springframework.http.HttpStatus +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +private val log = KotlinLogging.logger {} + +class DeepLTranslatorClient( + private val webClient: WebClient, +) : TranslatorClient { + + override fun requestTranslate(content: Content, targetLanguage: Language): Mono { + return webClient.post() + .uri("/v2/translate") + .bodyValue(DeepLRequest(content.language.shortName, targetLanguage.shortName, content.toText())) + .retrieve() + .onStatus({ it.isError }) { + log.info { "DeepL ErrorResponse=${it.bodyToMono().block()}" } // 에러 응답 확인용. 추후 불필요하면 삭제 + Mono.error(handleError(it)) + } + .bodyToMono() + .onErrorResume { Mono.error(handleConnectError(it)) } + .map { it.toContent(content.newsId, targetLanguage) } + } + + /** + * https://www.deepl.com/ko/docs-api/api-access/error-handling + */ + private fun handleError(clientResponse: ClientResponse): Exception { + val statusCode = clientResponse.statusCode() + return when (statusCode.value()) { + HttpStatus.TOO_MANY_REQUESTS.value() -> { + BadRequestException("단기간에 너무 많은 요청을 보냈습니다.") + } + + 456 -> { + log.error { "DeepL 할당량이 초과되었습니다." } + InternalServerError("할당량이 초과되었습니다. 관리자에게 문의하세요.") + } + + else -> { + log.warn { "DeepL 서버에 일시적 문제가 발생했습니다." } + InternalServerError("번역기 서버에 일시적 문제가 발생했습니다.") + } + } + } + + private fun handleConnectError(ex: Throwable): Exception { + return when (ex) { + is GalaxyhubException -> ex + else -> { + log.error(ex) { "DeepL 서버에 연결할 수 없습니다." } + InternalServerError("번역기 서버에 연결할 수 없습니다.") + } + } + } + + override fun getProvider(): TranslatorProvider { + return TranslatorProvider.DEEPL + } + + private fun Content.toText(): List { + val text = mutableListOf() + text.add(newsInformation.title) + text.add(newsInformation.excerpt ?: "") + text.addAll(content.split(System.lineSeparator())) + return text + } +} + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +private data class DeepLRequest( + val sourceLang: String, + val targetLang: String, + val text: List, +) + +internal data class DeepLResponse( + val translations: List, +) { + + fun toContent(newsId: UUID, language: Language): Content { + val newsInformation = toNewsInformation() + return Content( + newsId = newsId, + newsInformation = newsInformation, + language = language, + content = toContentString() + ) + } + + private fun toNewsInformation(): NewsInformation { + return translations.subList(0, 2) + .let { NewsInformation(it[0].text, it[1].text) } + } + + private fun toContentString(): String { + return translations.subList(2, translations.size) + .joinToString(System.lineSeparator()) { it.text } + } +} + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +internal data class DeepLSentenceResponse( + val detectedSourceLanguage: String, + val text: String, +) diff --git a/src/main/resources/db/migration/V3__add_translate_progression.sql b/src/main/resources/db/migration/V3__add_translate_progression.sql index 2bc6521..963f668 100644 --- a/src/main/resources/db/migration/V3__add_translate_progression.sql +++ b/src/main/resources/db/migration/V3__add_translate_progression.sql @@ -1,12 +1,14 @@ CREATE TABLE translate_progression ( - id BINARY(36) NOT NULL, - news_id BINARY(36) NOT NULL, - destination_language VARCHAR(255) NOT NULL, - translation_status VARCHAR(255) NOT NULL, - message VARCHAR(255) NULL, + id BINARY(16) NOT NULL, + news_id BINARY(16) NOT NULL, + source_language VARCHAR(255) NOT NULL, + target_language VARCHAR(255) NOT NULL, + translation_status VARCHAR(255) NOT NULL, + translator_provider VARCHAR(255) NOT NULL, + message VARCHAR(255) NULL, CONSTRAINT pk_translate_progression PRIMARY KEY (id) ); ALTER TABLE translate_progression - ADD CONSTRAINT UNIQUE_NEWS_ID_AND_DESTINATION_LANGUAGE UNIQUE (news_id, destination_language); + ADD CONSTRAINT UNIQUE_NEWS_ID_AND_TARGET_LANGUAGE UNIQUE (news_id, target_language); diff --git a/src/test/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1Test.kt b/src/test/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1Test.kt index eb93ca2..04e3c89 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1Test.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/api/v1/translation/TranslationControllerV1Test.kt @@ -18,6 +18,7 @@ import kr.galaxyhub.sc.translation.application.TranslationCommandService import kr.galaxyhub.sc.translation.application.TranslationQueryService import kr.galaxyhub.sc.translation.application.dto.TranslationResponse import kr.galaxyhub.sc.translation.domain.TranslationStatus +import kr.galaxyhub.sc.translation.domain.TranslatorProvider import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.http.MediaType @@ -36,7 +37,7 @@ class TranslationControllerV1Test( describe("POST /api/v1/translation/{newsId}") { context("유효한 요청이 전달되면") { - val request = TranslationRequest(Language.KOREAN) + val request = TranslationRequest(Language.ENGLISH, Language.KOREAN, TranslatorProvider.DEEPL) val newsId = UUID.randomUUID() every { translationCommandService.translate(any()) } returns UUID.randomUUID() @@ -51,7 +52,10 @@ class TranslationControllerV1Test( "newsId" pathMeans "번역할 뉴스의 식별자" ) requestBody( - "destinationLanguage" type ENUM(Language::class) means "번역 도착 언어" + "sourceLanguage" type ENUM(Language::class) means "번역할 뉴스의 원문 언어", + "targetLanguage" type ENUM(Language::class) means "번역을 원하는 언어", + "translatorProvider" type ENUM(TranslatorProvider.entries + .filter { it != TranslatorProvider.LOCAL }) means "번역 서비스 제공자" ) responseBody( "data" type STRING means "번역 진행 상황의 식별자" @@ -80,7 +84,11 @@ class TranslationControllerV1Test( "data.translateProgressionId" type STRING means "번역 진행 상황의 식별자", "data.targetNewsId" type STRING means "번역할 뉴스의 식별자", "data.translationStatus" type ENUM(TranslationStatus::class) means "번역 상태", - "data.message" type STRING means "번역 진행 상황의 추가적 메시지" isOptional true + "data.message" type STRING means "번역 진행 상황의 추가적 메시지" isOptional true, + "data.sourceLanguage" type ENUM(Language::class) means "번역할 뉴스의 원문 언어", + "data.targetLanguage" type ENUM(Language::class) means "번역을 원하는 언어", + "data.translationProvider" type ENUM(TranslatorProvider.entries + .filter { it != TranslatorProvider.LOCAL }) means "번역 서비스 제공자", ) } } @@ -92,4 +100,7 @@ private fun translationResponse(translateProgressionId: UUID) = TranslationRespo translateProgressionId = translateProgressionId, targetNewsId = UUID.randomUUID(), translationStatus = TranslationStatus.PROGRESS, + sourceLanguage = Language.ENGLISH, + targetLanguage = Language.KOREAN, + translationProvider = TranslatorProvider.DEEPL ) diff --git a/src/test/kotlin/kr/galaxyhub/sc/news/domain/NewsTest.kt b/src/test/kotlin/kr/galaxyhub/sc/news/domain/NewsTest.kt index f629c61..5313f32 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/news/domain/NewsTest.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/news/domain/NewsTest.kt @@ -36,7 +36,7 @@ class NewsTest : DescribeSpec({ context("컨텐츠의 뉴스가 동일하지 않으면") { val news = NewsFixture.create() val otherNews = NewsFixture.create() - val content = ContentFixture.from(Language.ENGLISH, otherNews) + val content = ContentFixture.create(language = Language.ENGLISH, newsId = otherNews.id) it("BadRequestException 예외를 던진다.") { val exception = shouldThrow { @@ -48,8 +48,8 @@ class NewsTest : DescribeSpec({ context("뉴스에 중복된 언어의 컨텐츠를 추가하면") { val news = NewsFixture.create() - val englishContent = ContentFixture.from(Language.ENGLISH, news) - val otherEnglishContent = ContentFixture.from(Language.ENGLISH, news) + val englishContent = ContentFixture.create(language = Language.ENGLISH, newsId = news.id) + val otherEnglishContent = ContentFixture.create(language = Language.ENGLISH, newsId = news.id) news.addContent(englishContent) @@ -63,8 +63,8 @@ class NewsTest : DescribeSpec({ context("뉴스에 중복되지 않은 언어의 컨텐츠를 추가하면") { val news = NewsFixture.create() - val englishContent = ContentFixture.from(Language.ENGLISH, news) - val koreanContent = ContentFixture.from(Language.KOREAN, news) + val englishContent = ContentFixture.create(language = Language.ENGLISH, newsId = news.id) + val koreanContent = ContentFixture.create(language = Language.KOREAN, newsId = news.id) news.addContent(englishContent) news.addContent(koreanContent) diff --git a/src/test/kotlin/kr/galaxyhub/sc/news/fixture/ContentFixture.kt b/src/test/kotlin/kr/galaxyhub/sc/news/fixture/ContentFixture.kt index 3bc5a27..078ce8e 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/news/fixture/ContentFixture.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/news/fixture/ContentFixture.kt @@ -1,31 +1,26 @@ package kr.galaxyhub.sc.news.fixture +import java.util.UUID import kr.galaxyhub.sc.news.domain.Content import kr.galaxyhub.sc.news.domain.Language -import kr.galaxyhub.sc.news.domain.News import kr.galaxyhub.sc.news.domain.NewsInformation object ContentFixture { - fun from(language: Language, news: News): Content = when (language) { - Language.ENGLISH -> Content( - newsInformation = NewsInformation( - title = "Star Citizen Live", - excerpt = "You asked. We're answering! Join us today for a live Q&A show with the Vehicle Gameplay team." - ), + fun create( + newsInformation: NewsInformation = NewsInformation( + title = "Star Citizen Live", + excerpt = "You asked. We're answering! Join us today for a live Q&A show with the Vehicle Gameplay team." + ), + language: Language = Language.ENGLISH, + content: String = "blah blah", + newsId: UUID = UUID.randomUUID(), + ): Content { + return Content( + newsInformation = newsInformation, language = language, - content = "blah blah", - news = news, - ) - - Language.KOREAN -> Content( - newsInformation = NewsInformation( - title = "스타 시티즌 뉴스", - excerpt = "물어보셨죠? 저희가 답해드리겠습니다! 지금 바로 차량 게임플레이 팀과 함께하는 라이브 Q&A 쇼에 참여하세요." - ), - language = language, - content = "어쩌구 저쩌구", - news = news, + content = content, + newsId = newsId ) } } diff --git a/src/test/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandServiceTest.kt b/src/test/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandServiceTest.kt new file mode 100644 index 0000000..8c6d772 --- /dev/null +++ b/src/test/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandServiceTest.kt @@ -0,0 +1,129 @@ +package kr.galaxyhub.sc.translation.application + +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.throwable.shouldHaveMessage +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kr.galaxyhub.sc.common.exception.BadRequestException +import kr.galaxyhub.sc.common.exception.NotFoundException +import kr.galaxyhub.sc.news.application.dto.NewsAppendContentEvent +import kr.galaxyhub.sc.news.domain.Language +import kr.galaxyhub.sc.news.domain.MemoryNewsRepository +import kr.galaxyhub.sc.news.fixture.ContentFixture +import kr.galaxyhub.sc.news.fixture.NewsFixture +import kr.galaxyhub.sc.translation.application.dto.TranslationCommand +import kr.galaxyhub.sc.translation.application.dto.TranslatorFailureEvent +import kr.galaxyhub.sc.translation.domain.MemoryTranslationProgressionRepository +import kr.galaxyhub.sc.translation.domain.TranslatorClient +import kr.galaxyhub.sc.translation.domain.TranslatorClients +import kr.galaxyhub.sc.translation.domain.TranslatorProvider +import kr.galaxyhub.sc.translation.domain.getOrThrow +import org.springframework.context.ApplicationEventPublisher +import reactor.core.publisher.Mono + +class TranslationCommandServiceTest : DescribeSpec({ + + val translationProgressionRepository = MemoryTranslationProgressionRepository() + val newsRepository = MemoryNewsRepository() + val translatorClient = mockk() + val eventPublisher = mockk() + val translationCommandService = TranslationCommandService( + translationProgressionRepository = translationProgressionRepository, + newsRepository = newsRepository, + translatorClients = TranslatorClients(mapOf(TranslatorProvider.LOCAL to translatorClient)), + eventPublisher = eventPublisher, + ) + + afterContainer { + clearAllMocks() + translationProgressionRepository.clear() + newsRepository.clear() + } + + describe("translate") { + context("뉴스에 번역을 원하는 언어의 컨텐츠가 존재하면") { + val news = NewsFixture.create() + news.addContent(ContentFixture.create(newsId = news.id, language = Language.ENGLISH)) + news.addContent(ContentFixture.create(newsId = news.id, language = Language.KOREAN)) + newsRepository.save(news) + val command = TranslationCommand(news.id, Language.ENGLISH, Language.KOREAN, TranslatorProvider.LOCAL) + + it("BadRequestException 예외를 던진다.") { + val ex = shouldThrow { + translationCommandService.translate(command) + } + ex shouldHaveMessage "이미 뉴스에 번역된 컨텐츠가 존재합니다. targetLanguage=${command.targetLanguage}" + } + } + + context("뉴스에 번역할 원문 언어의 컨텐츠가 없으면") { + val news = NewsFixture.create() + news.addContent(ContentFixture.create(newsId = news.id, language = Language.KOREAN)) + val command = TranslationCommand(news.id, Language.ENGLISH, Language.KOREAN, TranslatorProvider.LOCAL) + + it("BadRequestException 예외를 던진다.") { + val ex = shouldThrow { + translationCommandService.translate(command) + } + ex shouldHaveMessage "식별자와 언어에 대한 뉴스를 찾을 수 없습니다. id=${news.id}, language=${command.sourceLanguage}" + } + } + + context("translatorClient에 예외가 발생하면") { + val news = NewsFixture.create() + news.addContent(ContentFixture.create(newsId = news.id, language = Language.ENGLISH)) + newsRepository.save(news) + val command = TranslationCommand(news.id, Language.ENGLISH, Language.KOREAN, TranslatorProvider.LOCAL) + every { translatorClient.requestTranslate(any(), any()) } returns Mono.fromSupplier { + throw IllegalArgumentException() + } + every { eventPublisher.publishEvent(ofType()) } returns Unit + + val translateProgressionId = translationCommandService.translate(command) + + it("translationProgression가 저장된다.") { + shouldNotThrow { + translationProgressionRepository.getOrThrow(translateProgressionId) + } + } + + it("TranslatorFailureEvent 이벤트가 발행된다.") { + verify { eventPublisher.publishEvent(ofType()) } + } + + it("NewsAppendContentEvent 이벤트는 발행되지 않는다.") { + verify(exactly = 0) { eventPublisher.publishEvent(ofType()) } + } + } + + context("유효한 요청이 전달되면") { + val news = NewsFixture.create() + news.addContent(ContentFixture.create(newsId = news.id, language = Language.ENGLISH)) + newsRepository.save(news) + val command = TranslationCommand(news.id, Language.ENGLISH, Language.KOREAN, TranslatorProvider.LOCAL) + val translatedContent = ContentFixture.create(newsId = news.id, language = Language.KOREAN) + every { translatorClient.requestTranslate(any(), any()) } returns Mono.just(translatedContent) + every { eventPublisher.publishEvent(ofType()) } returns Unit + + val translateProgressionId = translationCommandService.translate(command) + + it("translationProgression가 저장된다.") { + shouldNotThrow { + translationProgressionRepository.getOrThrow(translateProgressionId) + } + } + + it("NewsAppendContentEvent 이벤트가 발행된다.") { + verify { eventPublisher.publishEvent(ofType()) } + } + + it("TranslatorFailureEvent 이벤트는 발행되지 않는다.") { + verify(exactly = 0) { eventPublisher.publishEvent(ofType()) } + } + } + } +}) diff --git a/src/test/kotlin/kr/galaxyhub/sc/translation/domain/MemoryTranslationProgressionRepository.kt b/src/test/kotlin/kr/galaxyhub/sc/translation/domain/MemoryTranslationProgressionRepository.kt new file mode 100644 index 0000000..2899a04 --- /dev/null +++ b/src/test/kotlin/kr/galaxyhub/sc/translation/domain/MemoryTranslationProgressionRepository.kt @@ -0,0 +1,19 @@ +package kr.galaxyhub.sc.translation.domain + +import java.util.UUID + +class MemoryTranslationProgressionRepository( + private val memory: MutableMap = mutableMapOf(), +) : TranslationProgressionRepository { + + fun clear() = memory.clear() + + override fun save(translateProgression: TranslateProgression): TranslateProgression { + memory[translateProgression.id] = translateProgression + return translateProgression + } + + override fun findById(translateProgressionId: UUID): TranslateProgression? { + return memory[translateProgressionId] + } +} diff --git a/src/test/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgressionTest.kt b/src/test/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgressionTest.kt new file mode 100644 index 0000000..ac20568 --- /dev/null +++ b/src/test/kotlin/kr/galaxyhub/sc/translation/domain/TranslateProgressionTest.kt @@ -0,0 +1,91 @@ +package kr.galaxyhub.sc.translation.domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage +import java.util.UUID +import kr.galaxyhub.sc.common.exception.BadRequestException +import kr.galaxyhub.sc.news.domain.Language + +class TranslateProgressionTest : DescribeSpec({ + + describe("constructor") { + context("번역 진행 상황이 생성되면") { + val translateProgression = translateProgression() + + it("translationStatus는 PROGRESS이다.") { + translateProgression.translationStatus shouldBe TranslationStatus.PROGRESS + } + + it("message는 null이다.") { + translateProgression.message shouldBe null + } + } + + context("sourceLanguage와 targetLanguage가 같으면") { + it("BadRequestException 예외를 던진다.") { + val ex = shouldThrow { + translateProgression( + sourceLanguage = Language.ENGLISH, + targetLanguage = Language.ENGLISH + ) + } + ex shouldHaveMessage "sourceLanguage와 targetLanguage는 같을 수 없습니다. language=ENGLISH" + } + } + } + + describe("changeComplete") { + val translateProgression = translateProgression() + + context("메서드를 호출하면") { + translateProgression.changeComplete() + + it("translationStatus가 COMPLETE로 변경된다.") { + translateProgression.translationStatus shouldBe TranslationStatus.COMPLETE + } + + it("message는 변경되지 않는다.") { + translateProgression.message shouldBe null + } + + context("translationStatus가 변경된 상태에서 호출하면") { + it("BadRequestException 예외를 던진다.") { + val ex = shouldThrow { translateProgression.changeComplete() } + ex shouldHaveMessage "translationStatus이 PROGRESS가 아닙니다. translationStatus=COMPLETE" + } + } + } + } + + describe("changeFailure") { + val translateProgression = translateProgression() + + context("메서드를 호출하면") { + translateProgression.changeFailure("예외가 발생했습니다.") + + it("translationStatus가 FAILURE로 변경된다.") { + translateProgression.translationStatus shouldBe TranslationStatus.FAILURE + } + + it("message가 변경된다.") { + translateProgression.message shouldBe "예외가 발생했습니다." + } + + context("translationStatus가 변경된 상태에서 호출하면") { + it("BadRequestException 예외를 던진다.") { + val ex = shouldThrow { translateProgression.changeComplete() } + ex shouldHaveMessage "translationStatus이 PROGRESS가 아닙니다. translationStatus=FAILURE" + } + } + } + } +}) + +private fun translateProgression( + newsId: UUID = UUID.randomUUID(), + sourceLanguage: Language = Language.ENGLISH, + targetLanguage: Language = Language.KOREAN, + translatorProvider: TranslatorProvider = TranslatorProvider.LOCAL, +) = TranslateProgression(newsId, sourceLanguage, targetLanguage, translatorProvider) diff --git a/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt b/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt new file mode 100644 index 0000000..8851265 --- /dev/null +++ b/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt @@ -0,0 +1,114 @@ +package kr.galaxyhub.sc.translation.infra + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage +import kr.galaxyhub.sc.common.exception.BadRequestException +import kr.galaxyhub.sc.common.exception.InternalServerError +import kr.galaxyhub.sc.common.support.enqueue +import kr.galaxyhub.sc.news.domain.Language +import kr.galaxyhub.sc.news.domain.NewsInformation +import kr.galaxyhub.sc.news.fixture.ContentFixture +import okhttp3.mockwebserver.MockWebServer +import org.springframework.web.reactive.function.client.WebClient + +class DeepLTranslatorClientTest : DescribeSpec({ + + isolationMode = IsolationMode.InstancePerLeaf + + val objectMapper = jacksonObjectMapper() + val mockWebServer = MockWebServer() + val deepLTranslatorClient = DeepLTranslatorClient( + webClient = WebClient.builder() + .baseUrl("${mockWebServer.url("/")}") + .build() + ) + + describe("requestTranslate") { + context("외부 서버가 200 응답을 반환하면") { + val response = DeepLResponse( + listOf( + DeepLSentenceResponse( + detectedSourceLanguage = "EN", + text = "제목입니다." + ), + DeepLSentenceResponse( + detectedSourceLanguage = "EN", + text = "발췌입니다." + ), + DeepLSentenceResponse( + detectedSourceLanguage = "EN", + text = "내용입니다." + ) + ) + ) + mockWebServer.enqueue { + statusCode(200) + body(objectMapper.writeValueAsString(response)) + } + + val expect = deepLTranslatorClient.requestTranslate(ContentFixture.create(), Language.KOREAN).block()!! + + it("번역된 응답이 반환된다.") { + assertSoftly { + expect.newsInformation shouldBe NewsInformation("제목입니다.", "발췌입니다.") + expect.content shouldBe "내용입니다." + } + } + } + + context("외부 서버가 429 응답을 반환하면") { + mockWebServer.enqueue { + statusCode(429) + } + + val expect = deepLTranslatorClient.requestTranslate(ContentFixture.create(), Language.KOREAN) + + it("BadRequestException 예외를 던진다.") { + val ex = shouldThrow { expect.block() } + ex shouldHaveMessage "단기간에 너무 많은 요청을 보냈습니다." + } + } + + context("외부 서버가 456 응답을 반환하면") { + mockWebServer.enqueue { + statusCode(456) + } + + val expect = deepLTranslatorClient.requestTranslate(ContentFixture.create(), Language.KOREAN) + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { expect.block() } + ex shouldHaveMessage "할당량이 초과되었습니다. 관리자에게 문의하세요." + } + } + + context("외부 서버가 그 외 응답을 반환하면") { + mockWebServer.enqueue { + statusCode(500) + } + + val expect = deepLTranslatorClient.requestTranslate(ContentFixture.create(), Language.KOREAN) + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { expect.block() } + ex shouldHaveMessage "번역기 서버에 일시적 문제가 발생했습니다." + } + } + + context("외부 서버에 연결할 수 없으면") { + mockWebServer.shutdown() + + val expect = deepLTranslatorClient.requestTranslate(ContentFixture.create(), Language.KOREAN) + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { expect.block() } + ex shouldHaveMessage "번역기 서버에 연결할 수 없습니다." + } + } + } +}) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index e04d483..d498092 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -26,3 +26,5 @@ galaxyhub: redirect_uri: http://localhost:8080/api/v1/auth/oauth2/code?provider=discord jwt: secret-key: galaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhubgalaxyhub + translator: + deepl-api-key: 018ca217-6dc6-7b92-888b-44dc684f3c27