diff --git a/src/docs/asciidoc/news-api.adoc b/src/docs/asciidoc/news-api.adoc index 0cdaf8e..cc2d878 100644 --- a/src/docs/asciidoc/news-api.adoc +++ b/src/docs/asciidoc/news-api.adoc @@ -27,3 +27,12 @@ include::{snippets}/news/find-detail/query-parameters.adoc[] *응답* include::{snippets}/news/find-detail/http-response.adoc[] include::{snippets}/news/find-detail/response-fields.adoc[] + +[[news-content-update]] +=== 뉴스 컨텐츠 수정 +*요청* +include::{snippets}/news/update-content/http-request.adoc[] +include::{snippets}/news/update-content/path-parameters.adoc[] +include::{snippets}/news/update-content/request-fields.adoc[] +*응답* +include::{snippets}/news/update-content/http-response.adoc[] diff --git a/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1.kt b/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1.kt index 2e57b89..b635426 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1.kt @@ -3,6 +3,7 @@ package kr.galaxyhub.sc.api.v1.news import java.util.UUID import kr.galaxyhub.sc.api.common.ApiResponse import kr.galaxyhub.sc.api.v1.news.dto.NewsCreateRequest +import kr.galaxyhub.sc.api.v1.news.dto.NewsUpdateRequest import kr.galaxyhub.sc.common.support.toUri import kr.galaxyhub.sc.news.application.NewsCommandService import kr.galaxyhub.sc.news.application.NewsQueryService @@ -32,12 +33,12 @@ class NewsControllerV1( .body(ApiResponse.success(response)) } - @GetMapping("/{id}") + @GetMapping("/{newsId}") fun findDetailById( - @PathVariable id: UUID, + @PathVariable newsId: UUID, @RequestParam language: Language, ): ResponseEntity> { - val response = newsQueryService.getDetailByIdAndLanguage(id, language) + val response = newsQueryService.getDetailByIdAndLanguage(newsId, language) return ResponseEntity.ok() .body(ApiResponse.success(response)) } @@ -46,8 +47,18 @@ class NewsControllerV1( fun create( @RequestBody request: NewsCreateRequest, ): ResponseEntity> { - val id = newsCommandService.create(request.toCommand()) - return ResponseEntity.created("/api/v1/news/${id}".toUri()) - .body(ApiResponse.success(id)) + val newsId = newsCommandService.create(request.toCommand()) + return ResponseEntity.created("/api/v1/news/${newsId}".toUri()) + .body(ApiResponse.success(newsId)) + } + + @PostMapping("/{newsId}/content") + fun updateContent( + @PathVariable newsId: UUID, + @RequestBody request: NewsUpdateRequest, + ): ResponseEntity> { + newsCommandService.updateContent(newsId, request.toCommand()) + return ResponseEntity.ok() + .body(ApiResponse.success(Unit)) } } diff --git a/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/dto/NewsUpdateRequest.kt b/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/dto/NewsUpdateRequest.kt new file mode 100644 index 0000000..65d4fe1 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/api/v1/news/dto/NewsUpdateRequest.kt @@ -0,0 +1,23 @@ +package kr.galaxyhub.sc.api.v1.news.dto + +import kr.galaxyhub.sc.news.application.NewsUpdateCommand +import kr.galaxyhub.sc.news.domain.Language +import kr.galaxyhub.sc.news.domain.NewsInformation + +data class NewsUpdateRequest( + val language: Language, + val title: String?, + val excerpt: String?, + val content: String?, +) { + + fun toCommand(): NewsUpdateCommand { + return NewsUpdateCommand( + language = language, + newsInformation = title?.let { + NewsInformation(title, excerpt) + }, + content = content + ) + } +} 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 ab108da..f8184d1 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsCommandService.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsCommandService.kt @@ -8,6 +8,7 @@ import kr.galaxyhub.sc.news.domain.News import kr.galaxyhub.sc.news.domain.NewsInformation import kr.galaxyhub.sc.news.domain.NewsRepository import kr.galaxyhub.sc.news.domain.NewsType +import kr.galaxyhub.sc.news.domain.getFetchByIdAndLanguage import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -33,6 +34,18 @@ class NewsCommandService( excerpt = command.excerpt ), ) + + fun updateContent(newsId: UUID, command: NewsUpdateCommand) { + val (language, newsInformation, content) = command + val news = newsRepository.getFetchByIdAndLanguage(newsId, language) + val contentByLanguage = news.getContentByLanguage(language) + newsInformation?.also { + contentByLanguage.updateNewsInformation(it) + } + content?.also { + contentByLanguage.updateContent(it) + } + } } data class NewsCreateCommand( @@ -53,3 +66,9 @@ data class NewsCreateCommand( originUrl = originUrl ) } + +data class NewsUpdateCommand( + val language: Language, + val newsInformation: NewsInformation?, + val content: String?, +) diff --git a/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsQueryService.kt b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsQueryService.kt index 45434fa..6cca8f1 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsQueryService.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/application/NewsQueryService.kt @@ -5,7 +5,7 @@ import kr.galaxyhub.sc.news.application.dto.NewsDetailResponse import kr.galaxyhub.sc.news.application.dto.NewsResponse import kr.galaxyhub.sc.news.domain.Language import kr.galaxyhub.sc.news.domain.NewsRepository -import kr.galaxyhub.sc.news.domain.getByDetailByIdAndLanguage +import kr.galaxyhub.sc.news.domain.getFetchByIdAndLanguage import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,7 +21,7 @@ class NewsQueryService( } fun getDetailByIdAndLanguage(id: UUID, language: Language): NewsDetailResponse { - return newsRepository.getByDetailByIdAndLanguage(id, language) + return newsRepository.getFetchByIdAndLanguage(id, language) .let { val content = it.getContentByLanguage(language) NewsDetailResponse.of(it, 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 e1ee3f3..45a1823 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/domain/Content.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/domain/Content.kt @@ -39,4 +39,12 @@ class Content( @Column(name = "content", nullable = false) var content: String = content protected set + + fun updateNewsInformation(newsInformation: NewsInformation) { + this.newsInformation = newsInformation + } + + fun updateContent(content: String) { + this.content = content + } } 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 acd0d8a..14e6709 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/news/domain/NewsRepository.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/news/domain/NewsRepository.kt @@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.Repository import org.springframework.data.repository.query.Param -fun NewsRepository.getByDetailByIdAndLanguage(id: UUID, language: Language) = findFetchByIdAndLanguage(id, language) +fun NewsRepository.getFetchByIdAndLanguage(id: UUID, language: Language) = findFetchByIdAndLanguage(id, language) ?: throw NotFoundException("식별자와 언어에 대한 뉴스를 찾을 수 없습니다. id=${id}, language=${language}") fun NewsRepository.getOrThrow(id: UUID) = findById(id) 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 e6301f2..3528050 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt @@ -5,7 +5,7 @@ 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.news.domain.getFetchByIdAndLanguage import kr.galaxyhub.sc.translation.application.dto.TranslationCommand import kr.galaxyhub.sc.translation.application.dto.TranslatorFailureEvent import kr.galaxyhub.sc.translation.domain.TranslateProgression @@ -28,7 +28,7 @@ class TranslationCommandService( fun translate(command: TranslationCommand): UUID { val (newsId, sourceLanguage, targetLanguage, translatorProvider) = command - val news = newsRepository.getByDetailByIdAndLanguage(newsId, sourceLanguage) + val news = newsRepository.getFetchByIdAndLanguage(newsId, sourceLanguage) validate(news.isSupportLanguage(targetLanguage)) { "이미 뉴스에 번역된 컨텐츠가 존재합니다. targetLanguage=$targetLanguage" } val content = news.getContentByLanguage(sourceLanguage) val translateProgression = TranslateProgression(newsId, sourceLanguage, targetLanguage, translatorProvider) diff --git a/src/test/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1Test.kt b/src/test/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1Test.kt index 7059c4f..b9a78f5 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1Test.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/api/v1/news/NewsControllerV1Test.kt @@ -4,11 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.ninjasquad.springmockk.MockkBean import io.kotest.core.spec.style.DescribeSpec import io.mockk.every +import io.mockk.justRun import java.time.LocalDateTime import java.time.ZoneId import java.time.ZonedDateTime import java.util.UUID -import kr.galaxyhub.sc.api.v1.news.dto.NewsCreateRequest import kr.galaxyhub.sc.api.support.ARRAY import kr.galaxyhub.sc.api.support.ENUM import kr.galaxyhub.sc.api.support.NUMBER @@ -21,6 +21,8 @@ import kr.galaxyhub.sc.api.support.docPost import kr.galaxyhub.sc.api.support.param import kr.galaxyhub.sc.api.support.pathMeans import kr.galaxyhub.sc.api.support.type +import kr.galaxyhub.sc.api.v1.news.dto.NewsCreateRequest +import kr.galaxyhub.sc.api.v1.news.dto.NewsUpdateRequest import kr.galaxyhub.sc.news.application.NewsCommandService import kr.galaxyhub.sc.news.application.NewsQueryService import kr.galaxyhub.sc.news.application.dto.NewsDetailResponse @@ -43,21 +45,21 @@ class NewsControllerV1Test( private val newsCommandService: NewsCommandService, ) : DescribeSpec({ - describe("GET /api/v1/news/{id}") { + describe("GET /api/v1/news/{newsId}") { context("유효한 요청이 전달되면") { - val id = UUID.randomUUID() - val response = newsDetailResponse(id) - every { newsQueryService.getDetailByIdAndLanguage(id, Language.ENGLISH) } returns response + val newsId = UUID.randomUUID() + val response = newsDetailResponse(newsId) + every { newsQueryService.getDetailByIdAndLanguage(newsId, Language.ENGLISH) } returns response it("200 응답과 뉴스의 상세 정보가 조회된다.") { - mockMvc.docGet("/api/v1/news/{id}", id) { + mockMvc.docGet("/api/v1/news/{newsId}", newsId) { contentType = MediaType.APPLICATION_JSON param("language" to Language.ENGLISH) }.andExpect { status { isOk() } }.andDocument("news/find-detail") { pathParameters( - "id" pathMeans "뉴스 식별자" constraint "UUID" + "newsId" pathMeans "뉴스 식별자" constraint "UUID" ) queryParameters( "language" pathMeans "뉴스 언어" formattedAs ENUM(Language::class) @@ -136,8 +138,42 @@ class NewsControllerV1Test( } } } + + describe("POST /api/v1/news/{newsId}/content") { + context("유효한 요청이 전달되면") { + val newsId = UUID.randomUUID() + val request = newsUpdateRequest() + justRun { newsCommandService.updateContent(any(), any()) } + + it("200 응답이 반환된다.") { + mockMvc.docPost("/api/v1/news/{newsId}/content", newsId) { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + }.andExpect { + status { isOk() } + }.andDocument("news/update-content") { + pathParameters( + "newsId" pathMeans "뉴스 식별자" constraint "UUID" + ) + requestBody( + "language" type ENUM(Language::class) means "뉴스 언어", + "title" type STRING means "뉴스 제목" isOptional true, + "excerpt" type STRING means "뉴스 발췌" isOptional true, + "content" type STRING means "뉴스 내용" isOptional true + ) + } + } + } + } }) +private fun newsUpdateRequest() = NewsUpdateRequest( + language = Language.ENGLISH, + title = "Star Citizen Live", + excerpt = "You asked. We're answering! Join us today for a live Q&A show with the Vehicle Gameplay team.", + content = "blah blah" +) + private fun newsCreateRequest() = NewsCreateRequest( newsType = NewsType.NEWS, title = "Star Citizen Live", diff --git a/src/test/kotlin/kr/galaxyhub/sc/news/application/NewsCommandServiceTest.kt b/src/test/kotlin/kr/galaxyhub/sc/news/application/NewsCommandServiceTest.kt index 5b4f080..4705a7f 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/news/application/NewsCommandServiceTest.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/news/application/NewsCommandServiceTest.kt @@ -1,8 +1,10 @@ package kr.galaxyhub.sc.news.application +import io.kotest.core.spec.IsolationMode import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import java.time.LocalDateTime import java.time.ZoneId @@ -11,14 +13,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kr.galaxyhub.sc.news.domain.Language import kr.galaxyhub.sc.news.domain.MemoryNewsRepository +import kr.galaxyhub.sc.news.domain.NewsInformation import kr.galaxyhub.sc.news.domain.NewsType +import kr.galaxyhub.sc.news.fixture.ContentFixture +import kr.galaxyhub.sc.news.fixture.NewsFixture class NewsCommandServiceTest( private val newsRepository: MemoryNewsRepository = MemoryNewsRepository(), private val newsCommandService: NewsCommandService = NewsCommandService(newsRepository), ) : DescribeSpec({ - beforeContainer { newsRepository.clear() } + isolationMode = IsolationMode.InstancePerLeaf describe("create") { context("originId에 대해 저장된 뉴스가 없으면") { @@ -53,6 +58,52 @@ class NewsCommandServiceTest( } } } + + describe("updateContent") { + val news = NewsFixture.create() + val content = ContentFixture.create( + newsId = news.id, + language = Language.ENGLISH, + newsInformation = NewsInformation("old", "old"), + content = "old", + ) + news.addContent(content) + newsRepository.save(news) + + context("Command의 newsInformation이 null이면") { + val command = NewsUpdateCommand( + language = Language.ENGLISH, + newsInformation = null, + content = "update" + ) + newsCommandService.updateContent(news.id, command) + + it("newsInformation은 수정되지 않는다.") { + content.newsInformation shouldBe NewsInformation("old", "old") + } + + it("content는 수정된다.") { + content.content shouldBe "update" + } + } + + context("Command의 content가 null이면") { + val command = NewsUpdateCommand( + language = Language.ENGLISH, + newsInformation = NewsInformation("update", "update"), + content = null + ) + newsCommandService.updateContent(news.id, command) + + it("content는 수정되지 않는다.") { + content.content shouldBe "old" + } + + it("newsInformation은 수정된다.") { + content.newsInformation shouldBe NewsInformation("update", "update") + } + } + } }) private fun newsCreateCommand(originId: Long) = NewsCreateCommand(