From ffe27fbb6857ab757a39e4e8007e17911b055006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=ED=9B=88?= Date: Sat, 27 Jul 2024 20:00:56 +0900 Subject: [PATCH] =?UTF-8?q?[Test/#250]=20Article=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C(=EB=AC=B4=ED=95=9C=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4)=20Mock=20API=20=EA=B5=AC=ED=98=84=20(RE)=20(#258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fix/#233] addArticleViewHis에서 runCatching 후 예외 발생시 트랜잭션 롤백되도록 수정 * feat: 아티클 목록 조회 목킹 API 프로토타입 * refacotr: ReadArticleResponse 에서 uc out dto 의존성 제거 * test: 아티클 목록 조회 컨트롤러 테스트 작성 (swagger 허브 등록) * test: 아티클 조회 includedWorkbooks 필드 Null 처리 * test: 아티클 단건 조회에서 includedWorkbooks 필드에 대한 Description 추가 * test: 응답 바디 type descrition 수정 * test: 응답 바디 type descrition 수정 (fieldWithObject -> fieldWithArray) * test: 아티클 목록 조회 API URI 일부 수정 * test: 아티클 목록 조회 API reponse body description 추가 * test: 학습지 목록 조회 응답 body type 수정 * test: article 목록 조회 API field description 수정 * test: field path 수정 (data.articles -> data.articles[]) * test: 아티클 단건 조회 테스트에서 fieild descritpion 수정 --- .../handler/ArticleViewHisAsyncHandler.kt | 2 +- .../article/usecase/ReadArticlesUseCase.kt | 15 +++ .../usecase/dto/ReadArticleUseCaseOut.kt | 6 + .../usecase/dto/ReadArticlesUseCaseIn.kt | 5 + .../usecase/dto/ReadArticlesUseCaseOut.kt | 5 + .../controller/article/ArticleController.kt | 67 +++++++++-- .../article/response/ReadArticleResponse.kt | 26 ++--- .../article/response/ReadArticlesResponse.kt | 6 + .../article/ArticleControllerTest.kt | 106 +++++++++++++++++- 9 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt create mode 100644 api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt create mode 100644 api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt create mode 100644 api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt diff --git a/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt b/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt index 7656163df..b15936bda 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/handler/ArticleViewHisAsyncHandler.kt @@ -34,6 +34,6 @@ class ArticleViewHisAsyncHandler( "Failed insertion article view history and upsertion article view count " + "for articleId: $articleId and memberId: $memberId" } - } + }.getOrThrow() } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt new file mode 100644 index 000000000..3b0df3b7f --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/ReadArticlesUseCase.kt @@ -0,0 +1,15 @@ +package com.few.api.domain.article.usecase + +import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseIn +import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseOut +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class ReadArticlesUseCase { + + @Transactional(readOnly = true) + fun execute(useCaseIn: ReadArticlesUseCaseIn): ReadArticlesUseCaseOut { + return ReadArticlesUseCaseOut(emptyList()) // TODO: impl + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt index 7425affa2..60aa85b8e 100644 --- a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticleUseCaseOut.kt @@ -12,10 +12,16 @@ data class ReadArticleUseCaseOut( val category: String, val createdAt: LocalDateTime, val views: Long, + val includedWorkbooks: List = emptyList(), ) data class WriterDetail( val id: Long, val name: String, val url: URL, +) + +data class WorkbookDetail( + val id: Long, + val title: String, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt new file mode 100644 index 000000000..0b144cd51 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseIn.kt @@ -0,0 +1,5 @@ +package com.few.api.domain.article.usecase.dto + +data class ReadArticlesUseCaseIn( + val prevArticleId: Long, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt new file mode 100644 index 000000000..fb2aedb64 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/domain/article/usecase/dto/ReadArticlesUseCaseOut.kt @@ -0,0 +1,5 @@ +package com.few.api.domain.article.usecase.dto + +data class ReadArticlesUseCaseOut( + val articles: List, +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt b/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt index 0eeff9c42..6aa1fd04d 100644 --- a/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt +++ b/api/src/main/kotlin/com/few/api/web/controller/article/ArticleController.kt @@ -2,23 +2,26 @@ package com.few.api.web.controller.article import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn import com.few.api.domain.article.usecase.ReadArticleUseCase +import com.few.api.domain.article.usecase.ReadArticlesUseCase +import com.few.api.domain.article.usecase.dto.ReadArticlesUseCaseIn import com.few.api.web.controller.article.response.ReadArticleResponse +import com.few.api.web.controller.article.response.ReadArticlesResponse +import com.few.api.web.controller.article.response.WorkbookInfo +import com.few.api.web.controller.article.response.WriterInfo import com.few.api.web.support.ApiResponse import com.few.api.web.support.ApiResponseGenerator import jakarta.validation.constraints.Min import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @Validated @RestController @RequestMapping(value = ["/api/v1/articles"], produces = [MediaType.APPLICATION_JSON_VALUE]) class ArticleController( private val readArticleUseCase: ReadArticleUseCase, + private val readArticlesUseCase: ReadArticlesUseCase, ) { @GetMapping("/{articleId}") @@ -31,8 +34,58 @@ class ArticleController( readArticleUseCase.execute(useCaseIn) } - return ReadArticleResponse(useCaseOut).let { - ApiResponseGenerator.success(it, HttpStatus.OK) - } + val response = ReadArticleResponse( + id = useCaseOut.id, + title = useCaseOut.title, + writer = WriterInfo( + useCaseOut.writer.id, + useCaseOut.writer.name, + useCaseOut.writer.url + ), + content = useCaseOut.content, + problemIds = useCaseOut.problemIds, + category = useCaseOut.category, + createdAt = useCaseOut.createdAt, + views = useCaseOut.views + ) + + return ApiResponseGenerator.success(response, HttpStatus.OK) + } + + @GetMapping + fun readArticles( + @RequestParam( + required = false, + defaultValue = "0" + ) prevArticleId: Long, + ): ApiResponse> { + val useCaseOut = readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId)) + + val articles: List = useCaseOut.articles.map { a -> + ReadArticleResponse( + id = a.id, + title = a.title, + writer = WriterInfo( + a.writer.id, + a.writer.name, + a.writer.url + ), + content = a.content, + problemIds = a.problemIds, + category = a.category, + createdAt = a.createdAt, + views = a.views, + includedWorkbooks = a.includedWorkbooks.map { w -> + WorkbookInfo( + id = w.id, + title = w.title + ) + } + ) + }.toList() + + val response = ReadArticlesResponse(articles, articles.size != 10) // TODO refactor 'isLast' + + return ApiResponseGenerator.success(response, HttpStatus.OK) } } \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt index 3d6dd1e2c..5ae6c7a76 100644 --- a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt +++ b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticleResponse.kt @@ -1,6 +1,5 @@ package com.few.api.web.controller.article.response -import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseOut import java.net.URL import java.time.LocalDateTime @@ -13,27 +12,16 @@ data class ReadArticleResponse( val category: String, val createdAt: LocalDateTime, val views: Long, -) { - constructor( - useCaseOut: ReadArticleUseCaseOut, - ) : this( - id = useCaseOut.id, - writer = WriterInfo( - id = useCaseOut.writer.id, - name = useCaseOut.writer.name, - url = useCaseOut.writer.url - ), - title = useCaseOut.title, - content = useCaseOut.content, - problemIds = useCaseOut.problemIds, - category = useCaseOut.category, - createdAt = useCaseOut.createdAt, - views = useCaseOut.views - ) -} + val includedWorkbooks: List = emptyList(), +) data class WriterInfo( val id: Long, val name: String, val url: URL, +) + +data class WorkbookInfo( + val id: Long, + val title: String, ) \ No newline at end of file diff --git a/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt new file mode 100644 index 000000000..80a139400 --- /dev/null +++ b/api/src/main/kotlin/com/few/api/web/controller/article/response/ReadArticlesResponse.kt @@ -0,0 +1,6 @@ +package com.few.api.web.controller.article.response + +data class ReadArticlesResponse( + val articles: List, + val isLast: Boolean, +) \ No newline at end of file diff --git a/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt b/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt index 3c1059b58..4807efdcb 100644 --- a/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt +++ b/api/src/test/kotlin/com/few/api/web/controller/article/ArticleControllerTest.kt @@ -5,10 +5,9 @@ import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.epages.restdocs.apispec.ResourceSnippetParameters import com.epages.restdocs.apispec.Schema import com.fasterxml.jackson.databind.ObjectMapper -import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseIn -import com.few.api.domain.article.usecase.dto.ReadArticleUseCaseOut -import com.few.api.domain.article.usecase.dto.WriterDetail import com.few.api.domain.article.usecase.ReadArticleUseCase +import com.few.api.domain.article.usecase.ReadArticlesUseCase +import com.few.api.domain.article.usecase.dto.* import com.few.api.web.controller.ControllerTestSpec import com.few.api.web.controller.description.Description import com.few.api.web.controller.helper.* @@ -43,6 +42,9 @@ class ArticleControllerTest : ControllerTestSpec() { @MockBean private lateinit var readArticleUseCase: ReadArticleUseCase + @MockBean + private lateinit var readArticlesUseCase: ReadArticlesUseCase + companion object { private val BASE_URL = "/api/v1/articles" private val TAG = "ArticleController" @@ -96,7 +98,7 @@ class ArticleControllerTest : ControllerTestSpec() { ResourceSnippetParameters.builder().description("아티클 Id로 아티클 조회") .summary(api.toIdentifier()).privateResource(false).deprecated(false) .tag(TAG).requestSchema(Schema.schema(api.toRequestSchema())) - .pathParameters(parameterWithName("articleId").description("학습지 Id")) + .pathParameters(parameterWithName("articleId").description("아티클 Id")) .responseSchema(Schema.schema(api.toResponseSchema())).responseFields( *Description.describe( arrayOf( @@ -123,7 +125,101 @@ class ArticleControllerTest : ControllerTestSpec() { PayloadDocumentation.fieldWithPath("data.createdAt") .fieldWithString("아티클 생성일"), PayloadDocumentation.fieldWithPath("data.views") - .fieldWithNumber("아티클 조회수") + .fieldWithNumber("아티클 조회수"), + PayloadDocumentation.fieldWithPath("data.includedWorkbooks") + .fieldWithArray("아티클이 포함된 학습지 정보(해당 API에서 사용되지 않음)") + ) + ) + ).build() + ) + ) + ) + } + + @Test + @DisplayName("[GET] /api/v1/articles?prevArticleId={prevArticleId}") + fun readArticles() { + // given + val api = "ReadArticles" + val uri = UriComponentsBuilder.newInstance() + .path("$BASE_URL") + .queryParam("prevArticleId", 1L) + .build() + .toUriString() + // set usecase mock + val prevArticleId = 1L + `when`(readArticlesUseCase.execute(ReadArticlesUseCaseIn(prevArticleId))).thenReturn( + ReadArticlesUseCaseOut( + listOf( + ReadArticleUseCaseOut( + id = 1L, + writer = WriterDetail( + id = 1L, + name = "안나포", + url = URL("http://localhost:8080/api/v1/writers/1") + ), + title = "ETF(상장 지수 펀드)란? 모르면 손해라고?", + content = CategoryType.fromCode(0)!!.name, + problemIds = listOf(1L, 2L, 3L), + category = "경제", + createdAt = LocalDateTime.now(), + views = 1L, + includedWorkbooks = listOf( + WorkbookDetail(1L, "사소한 것들의 역사"), + WorkbookDetail(2L, "인모스트 경제레터") + ) + ) + ) + ) + ) + + // when + this.webTestClient.get().uri(uri).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody().consumeWith( + WebTestClientRestDocumentation.document( + api.toIdentifier(), + ResourceDocumentation.resource( + ResourceSnippetParameters.builder().description("아티 목록 10개씩 조회(조회수 기반 정렬)") + .summary(api.toIdentifier()).privateResource(false).deprecated(false) + .tag(TAG).requestSchema(Schema.schema(api.toRequestSchema())) + .queryParameters(parameterWithName("prevArticleId").description("이전까지 조회한 아티클 Id")) + .responseSchema(Schema.schema(api.toResponseSchema())).responseFields( + *Description.describe( + arrayOf( + PayloadDocumentation.fieldWithPath("data") + .fieldWithObject("data"), + PayloadDocumentation.fieldWithPath("data.isLast") + .fieldWithBoolean("마지막 스크롤 유무"), + PayloadDocumentation.fieldWithPath("data.articles") + .fieldWithArray("아티클 목록"), + PayloadDocumentation.fieldWithPath("data.articles[].id") + .fieldWithNumber("아티클 Id"), + PayloadDocumentation.fieldWithPath("data.articles[].writer") + .fieldWithObject("아티클 작가"), + PayloadDocumentation.fieldWithPath("data.articles[].writer.id") + .fieldWithNumber("아티클 작가 Id"), + PayloadDocumentation.fieldWithPath("data.articles[].writer.name") + .fieldWithString("아티클 작가 이름"), + PayloadDocumentation.fieldWithPath("data.articles[].writer.url") + .fieldWithString("아티클 작가 링크"), + PayloadDocumentation.fieldWithPath("data.articles[].title") + .fieldWithString("아티클 제목"), + PayloadDocumentation.fieldWithPath("data.articles[].content") + .fieldWithString("아티클 내용"), + PayloadDocumentation.fieldWithPath("data.articles[].problemIds") + .fieldWithArray("아티클 문제 목록"), + PayloadDocumentation.fieldWithPath("data.articles[].category") + .fieldWithString("아티클 카테고리"), + PayloadDocumentation.fieldWithPath("data.articles[].createdAt") + .fieldWithString("아티클 생성일"), + PayloadDocumentation.fieldWithPath("data.articles[].views") + .fieldWithNumber("아티클 조회수"), + PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks") + .fieldWithArray("아티클이 포함된 학습지 정보"), + PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks[].id") + .fieldWithNumber("아티클이 포함된 학습지 정보(학습지ID)"), + PayloadDocumentation.fieldWithPath("data.articles[].includedWorkbooks[].title") + .fieldWithString("아티클이 포함된 학습지 정보(학습지 제목)") ) ) ).build()