diff --git a/build.gradle.kts b/build.gradle.kts index 32c6205..d5fb9ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") annotationProcessor("org.projectlombok:lombok") testImplementation("org.springframework.boot:spring-boot-starter-data-jdbc-test") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/ArticleException.kt b/src/main/kotlin/com/wafflestudio/team2server/article/ArticleException.kt new file mode 100644 index 0000000..e46c770 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/ArticleException.kt @@ -0,0 +1,54 @@ +package com.wafflestudio.team2server.article + +import com.wafflestudio.team2server.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class ArticleException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class ArticleNotFoundException : + ArticleException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "Article not found", + ) + +class ArticleBlankContentException : + ArticleException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Content must not be blank", + ) + +class ArticleBlankAuthorException : + ArticleException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Author must not be blank", + ) + +class ArticleBlankPublishedException : + ArticleException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "PublishedAt must not be blank", + ) + +class ArticleBlankOriginLinkException : + ArticleException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "OriginLink must not be blank", + ) + +class ArticleBlankTitleException : + ArticleException( + errorCode = 0, + httpStatusCode = HttpStatus.BAD_REQUEST, + msg = "Title must not be blank", + ) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/controller/ArticleController.kt b/src/main/kotlin/com/wafflestudio/team2server/article/controller/ArticleController.kt new file mode 100644 index 0000000..75dabd9 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/controller/ArticleController.kt @@ -0,0 +1,164 @@ +package com.wafflestudio.team2server.article.controller + +import com.wafflestudio.team2server.article.dto.UpdateArticleRequest +import com.wafflestudio.team2server.article.dto.core.ArticleDto +import com.wafflestudio.team2server.article.dto.request.CreateArticleRequest +import com.wafflestudio.team2server.article.dto.response.ArticlePagingResponse +import com.wafflestudio.team2server.article.dto.response.CreateArticleResponse +import com.wafflestudio.team2server.article.service.ArticleService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.Instant + +@RestController +@RequestMapping("/api/v1") +@Tag(name = "Article", description = "게시글 관리 API") +class ArticleController( + private val articleService: ArticleService, +) { + @Operation(summary = "게시글 생성", description = "게시판에 글이 작성되는지 확인.") + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "게시글 생성"), + ApiResponse(responseCode = "400", description = "잘못된 요청(제목, 글쓴이, 원본링크, 글 작성시간 중 하나가 작성되지 않음)"), + ApiResponse(responseCode = "404", description = "게시판을 찾을 수 없음"), + ], + ) + @PostMapping("/boards/{boardId}/articles") + fun create( + @Parameter( + description = "게시판 ID", + example = "1", + ) @PathVariable boardId: Long, + @RequestBody createArticleRequest: CreateArticleRequest, + ): ResponseEntity { + val articleDto = + articleService.create( + content = createArticleRequest.content, + title = createArticleRequest.title, + author = createArticleRequest.author, + originLink = createArticleRequest.originLink, + publihedAt = createArticleRequest.publishedAt, + boardId = boardId, + ) + return ResponseEntity.ok(articleDto) + } + + @Operation( + summary = "게시글 목록 조회", + description = """ + 게시판의 게시글을 페이지네이션 해서 가져옴 + 커서 기반 페이지 네이션 사용 + 정렬: 게시글 생성시간 내림차순 + """, + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "게시글 목록 조회 성공"), + ApiResponse(responseCode = "404", description = "게시판을 찾을 수 없음"), + ], + ) + @GetMapping("/boards/{boardId}/articles") + fun paging( + @Parameter(description = "게시판 ID", example = "1") + @PathVariable boardId: Long, + @Parameter( + description = "다음 페이지 커서 - 이전 응답의 마지막 게시글 생성 시간 (Unix timestamp, milliseconds)", + ) @RequestParam(value = "nextPublishedAt", required = false) nextPublishedAt: Long?, + @Parameter( + description = "다음 페이지 커서 - 이전 응답의 마지막 게시글 ID (nextPublishedAt와 함께 사용)", + ) @RequestParam(value = "nextId", required = false) nextId: Long?, + @Parameter( + description = "페이지당 게시글 수", + example = "20", + ) @RequestParam(value = "limit", defaultValue = "20") limit: Int, + ): ResponseEntity { + val articlePagingResponse = + articleService.pageByBoardId( + boardId = boardId, + nextPublishedAt = nextPublishedAt?.let { Instant.ofEpochMilli(it) }, + nextId = nextId, + limit = limit, + ) + return ResponseEntity.ok(articlePagingResponse) + } + + @Operation(summary = "특정 게시글 조회", description = "게시글 ID로 게시글 상세 정보 조회") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "특정 게시글 조회 성공"), + ApiResponse(responseCode = "404", description = "게시판을 찾을 수 없습니다."), + ], + ) + @GetMapping("/articles/{articleId}") + fun get( + @Parameter( + description = "게시글 ID", + example = "1", + ) @PathVariable articleId: Long, + ): ResponseEntity { + val articleDto = articleService.get(articleId) + return ResponseEntity.ok(articleDto) + } + + @Operation(summary = "특정 게시글 업데이트", description = "게시글 ID로 게시글 상세 정보 업데이트") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "특정 게시글 업데이트 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청(제목, 글쓴이, 원본링크, 글 작성시간 중 하나가 작성되지 않음)"), + ApiResponse(responseCode = "404", description = "게시글을 찾을 수 없습니다."), + ], + ) + @PatchMapping("/articles/{articleId}") + fun update( + @Parameter( + description = "게시글 ID", + example = "1", + ) + @PathVariable articleId: Long, + @RequestBody updateArticleRequest: UpdateArticleRequest, + ): ResponseEntity { + val articleDto = + articleService.update( + articleId = articleId, + content = updateArticleRequest.content, + author = updateArticleRequest.author, + originLink = updateArticleRequest.originLink, + title = updateArticleRequest.title, + publishedAt = updateArticleRequest.publishedAt, + ) + return ResponseEntity.ok(articleDto) + } + + @Operation(summary = "특정 게시글 삭제", description = "게시글 ID로 게시글 삭제") + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "특정 게시글 삭제 성공"), + ApiResponse(responseCode = "404", description = "게시글을 찾을 수 없습니다."), + ], + ) + @DeleteMapping("/articles/{articleId}") + fun delete( + @Parameter( + description = "게시글 ID", + example = "1", + ) + @PathVariable articleId: Long, + ): ResponseEntity { + articleService.delete(articleId) + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/ArticlePaging.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/ArticlePaging.kt new file mode 100644 index 0000000..23ac7d3 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/ArticlePaging.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.team2server.article.dto + +data class ArticlePaging( + val nextPublishedAt: Long?, + val nextId: Long?, + val hasNext: Boolean, +) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/core/ArticleDto.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/core/ArticleDto.kt new file mode 100644 index 0000000..1507aff --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/core/ArticleDto.kt @@ -0,0 +1,47 @@ +package com.wafflestudio.team2server.article.dto.core + +import com.wafflestudio.team2server.article.model.Article +import com.wafflestudio.team2server.article.model.ArticleWithBoard +import com.wafflestudio.team2server.board.dto.core.BoardDto +import com.wafflestudio.team2server.board.model.Board + +data class ArticleDto( + val id: Long, + val board: BoardDto, + val content: String, + val author: String, + val originLink: String, + val title: String, + val publishedAt: Long, + val createdAt: Long, + val updatedAt: Long, +) { + constructor(article: Article, board: Board) : this( + id = article.id!!, + board = BoardDto(board), + content = article.content, + author = article.author, + originLink = article.originLink, + title = article.title, + publishedAt = article.publishedAt.toEpochMilli(), + createdAt = article.createdAt!!.toEpochMilli(), + updatedAt = article.updatedAt!!.toEpochMilli(), + ) + + constructor(articleWithBoard: ArticleWithBoard) : this( + id = articleWithBoard.id, + board = + BoardDto( + id = articleWithBoard.board!!.id, + name = articleWithBoard.board.name, + sourceUrl = articleWithBoard.board.sourceUrl, + ), + content = articleWithBoard.content, + author = articleWithBoard.author, + originLink = articleWithBoard.originLink, + title = articleWithBoard.title, + publishedAt = articleWithBoard.publishedAt.toEpochMilli(), + createdAt = articleWithBoard.createdAt.toEpochMilli(), + updatedAt = articleWithBoard.updatedAt.toEpochMilli(), + ) +} diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/ArticlePagingRequest.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/ArticlePagingRequest.kt new file mode 100644 index 0000000..49d8e61 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/ArticlePagingRequest.kt @@ -0,0 +1,23 @@ +package com.wafflestudio.team2server.article.dto + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "게시글 목록 커서 페이지네이션 요청") +data class ArticlePagingRequest( + @Schema( + description = "다음 페이지 커서( 게시글 생성 시간)", + required = false, + ) + val nextPublishedAt: Long? = null, + @Schema( + description = "다음 페이지 커서(게시글 ID)", + required = false, + ) + val nextId: Long? = null, + @Schema( + description = "페이지당 게시글 수", + example = "30", + required = false, + ) + val limit: Int = 30, +) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/CreateArticleRequest.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/CreateArticleRequest.kt new file mode 100644 index 0000000..46efc3c --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/CreateArticleRequest.kt @@ -0,0 +1,38 @@ +package com.wafflestudio.team2server.article.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.Instant + +@Schema(description = "게시글 생성 요청") +data class CreateArticleRequest( + @Schema( + description = "게시글 제목", + example = "교내 장학금 신청 안내", + required = true, + ) + val title: String, + @Schema( + description = "게시글 본문 내용", + example = "mysnu에서 신청하세요...", + required = true, + ) + val content: String, + @Schema( + description = "기사 작성자", + example = "행정부", + required = true, + ) + val author: String, + @Schema( + description = "원문 기사 링크", + example = "https://www.mysnu/...", + required = true, + ) + val originLink: String, + @Schema( + description = "기사 실제 게시 시각", + example = "2026-01-01T12:00:00Z", + required = true, + ) + val publishedAt: Instant, +) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/UpdateArticleRequest.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/UpdateArticleRequest.kt new file mode 100644 index 0000000..1e755ee --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/request/UpdateArticleRequest.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.team2server.article.dto + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.Instant + +@Schema(description = "게시글 수정 요청") +data class UpdateArticleRequest( + @Schema(description = "게시글 제목", example = "수정된 제목") + val title: String? = null, + @Schema(description = "게시글 내용", example = "수정된 내용") + val content: String? = null, + @Schema(description = "게시글 작성자", example = "다른 작성자") + val author: String? = null, + @Schema(description = "원문 게시글 링크", example = "www.youtube.com") + val originLink: String? = null, + @Schema(description = "게시글 작성 시각") + val publishedAt: Instant? = null, +) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/response/ArticlePagingResponse.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/response/ArticlePagingResponse.kt new file mode 100644 index 0000000..38b31b3 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/response/ArticlePagingResponse.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.team2server.article.dto.response + +import com.wafflestudio.team2server.article.dto.ArticlePaging +import com.wafflestudio.team2server.article.dto.core.ArticleDto + +data class ArticlePagingResponse( + val data: List, + val paging: ArticlePaging, +) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/dto/response/CreateArticleResponse.kt b/src/main/kotlin/com/wafflestudio/team2server/article/dto/response/CreateArticleResponse.kt new file mode 100644 index 0000000..aa07888 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/dto/response/CreateArticleResponse.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.team2server.article.dto.response + +import com.wafflestudio.team2server.article.dto.core.ArticleDto + +typealias CreateArticleResponse = ArticleDto diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/model/Article.kt b/src/main/kotlin/com/wafflestudio/team2server/article/model/Article.kt index 716ec60..6bf0c6e 100644 --- a/src/main/kotlin/com/wafflestudio/team2server/article/model/Article.kt +++ b/src/main/kotlin/com/wafflestudio/team2server/article/model/Article.kt @@ -1,19 +1,23 @@ package com.wafflestudio.team2server.article.model +import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.Id +import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.relational.core.mapping.Table import java.time.Instant @Table("articles") data class Article( @Id - val id: Long? = null, - val boardId: Long, - val content: String, - val author: String, - val title: String, - val originLink: String, - val publishedAt: Instant, - val createdAt: Instant? = null, - val updatedAt: Instant? = null, + var id: Long? = null, + var boardId: Long, + var content: String, + var author: String, + var title: String, + var originLink: String, + var publishedAt: Instant, + @CreatedDate + var createdAt: Instant? = null, + @LastModifiedDate + var updatedAt: Instant? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/model/ArticleWithBoard.kt b/src/main/kotlin/com/wafflestudio/team2server/article/model/ArticleWithBoard.kt new file mode 100644 index 0000000..c361618 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/model/ArticleWithBoard.kt @@ -0,0 +1,22 @@ +package com.wafflestudio.team2server.article.model +import org.springframework.data.relational.core.mapping.Embedded +import java.time.Instant + +data class ArticleWithBoard( + val id: Long, + @Embedded.Nullable(prefix = "board_") + val board: Board?, + val content: String, + val author: String, + val title: String, + val originLink: String, + val publishedAt: Instant, + val createdAt: Instant, + val updatedAt: Instant, +) { + data class Board( + val id: Long, + val name: String, + val sourceUrl: String, + ) +} diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/repository/ArticleRepository.kt b/src/main/kotlin/com/wafflestudio/team2server/article/repository/ArticleRepository.kt index 40f1903..24b52d0 100644 --- a/src/main/kotlin/com/wafflestudio/team2server/article/repository/ArticleRepository.kt +++ b/src/main/kotlin/com/wafflestudio/team2server/article/repository/ArticleRepository.kt @@ -1,6 +1,66 @@ package com.wafflestudio.team2server.article.repository import com.wafflestudio.team2server.article.model.Article +import com.wafflestudio.team2server.article.model.ArticleWithBoard +import org.springframework.data.jdbc.repository.query.Query import org.springframework.data.repository.ListCrudRepository +import org.springframework.data.repository.query.Param +import java.time.Instant -interface ArticleRepository : ListCrudRepository +interface ArticleRepository : ListCrudRepository { + @Query( + """ + SELECT + a.id AS id, + a.content AS content, + a.author AS author, + a.title AS title, + a.origin_link AS origin_link, + a.published_at AS published_at, + a.created_at AS created_at, + a.updated_at AS updated_at, + + b.id AS board_id, + b.name AS board_name, + b.source_url AS board_source_url + FROM articles a + LEFT JOIN boards b + ON a.board_id = b.id + WHERE a.id = :articleId + """, + ) + fun findByIdWithBoard( + @Param("articleId") articleId: Long, + ): ArticleWithBoard? + + @Query( + """ + SELECT + a.id AS id, + a.title AS title, + a.content AS content, + a.author AS author, + a.origin_link AS origin_link, + a.published_at AS published_at, + a.created_at AS created_at, + a.updated_at AS updated_at, + + b.id AS board_id, + b.name AS board_name, + b.source_url AS board_source_url + FROM articles a + LEFT JOIN boards b + ON a.board_id = b.id + WHERE a.board_id = :boardId + AND (:nextPublishedAt IS NULL OR (a.published_at, a.id) < (:nextPublishedAt, :nextId)) + ORDER BY a.published_at DESC, a.id DESC + LIMIT :limit + """, + ) + fun findByBoardIdWithCursor( + @Param("boardId") boardId: Long, + @Param("nextPublishedAt") nextPublishedAt: Instant?, + @Param("nextId") nextId: Long?, + @Param("limit") limit: Int, + ): List +} diff --git a/src/main/kotlin/com/wafflestudio/team2server/article/service/ArticleService.kt b/src/main/kotlin/com/wafflestudio/team2server/article/service/ArticleService.kt new file mode 100644 index 0000000..4feec08 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/article/service/ArticleService.kt @@ -0,0 +1,129 @@ +package com.wafflestudio.team2server.article.service + +import com.wafflestudio.team2server.article.ArticleBlankAuthorException +import com.wafflestudio.team2server.article.ArticleBlankContentException +import com.wafflestudio.team2server.article.ArticleBlankOriginLinkException +import com.wafflestudio.team2server.article.ArticleBlankPublishedException +import com.wafflestudio.team2server.article.ArticleBlankTitleException +import com.wafflestudio.team2server.article.ArticleNotFoundException +import com.wafflestudio.team2server.article.dto.ArticlePaging +import com.wafflestudio.team2server.article.dto.core.ArticleDto +import com.wafflestudio.team2server.article.dto.response.ArticlePagingResponse +import com.wafflestudio.team2server.article.model.Article +import com.wafflestudio.team2server.article.repository.ArticleRepository +import com.wafflestudio.team2server.board.BoardNotFoundException +import com.wafflestudio.team2server.board.repository.BoardRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ArticleService( + private val articleRepository: ArticleRepository, + private val boardRepository: BoardRepository, +) { + fun get(articleId: Long): ArticleDto { + val articleWithBoard = + articleRepository.findByIdWithBoard(articleId) + ?: throw ArticleNotFoundException() + + return ArticleDto(articleWithBoard) + } + + fun pageByBoardId( + boardId: Long, + nextPublishedAt: Instant?, + nextId: Long?, + limit: Int, + ): ArticlePagingResponse { + val board = boardRepository.findByIdOrNull(boardId) ?: throw BoardNotFoundException() + + val queryLimit = limit + 1 + val articleWithBoards = + articleRepository.findByBoardIdWithCursor(board.id!!, nextPublishedAt, nextId, queryLimit) + val hasNext = articleWithBoards.size > limit + val pageArticles = if (hasNext) articleWithBoards.subList(0, limit) else articleWithBoards + val newNextPublishedAt = if (hasNext) pageArticles.last().publishedAt else null + val newnextId = if (hasNext) pageArticles.last().id else null + return ArticlePagingResponse( + pageArticles.map { ArticleDto(it) }, + ArticlePaging(newNextPublishedAt?.toEpochMilli(), newnextId, hasNext), + ) + } + + fun create( + content: String, + author: String, + originLink: String, + title: String, + boardId: Long, + publihedAt: Instant?, + ): ArticleDto { + if (content.isBlank()) { + throw ArticleBlankContentException() + } + if (author.isBlank()) { + throw ArticleBlankAuthorException() + } + if (publihedAt == null) { + throw ArticleBlankPublishedException() + } + if (originLink.isBlank()) { + throw ArticleBlankOriginLinkException() + } + if (title.isBlank()) { + throw ArticleBlankTitleException() + } + val board = boardRepository.findByIdOrNull(boardId) ?: throw BoardNotFoundException() + + val article = + articleRepository.save( + Article( + boardId = board.id!!, + content = content, + author = author, + originLink = originLink, + title = title, + publishedAt = publihedAt, + ), + ) + return ArticleDto(article, board) + } + + fun update( + articleId: Long, + content: String?, + author: String?, + originLink: String?, + title: String?, + publishedAt: Instant?, + ): ArticleDto { + if (content?.isBlank() == true) { + throw ArticleBlankContentException() + } + if (author?.isBlank() == true) { + throw ArticleBlankAuthorException() + } + if (originLink?.isBlank() == true) { + throw ArticleBlankOriginLinkException() + } + if (title?.isBlank() == true) { + throw ArticleBlankTitleException() + } + val article = articleRepository.findByIdOrNull(articleId) ?: throw ArticleNotFoundException() + content?.let { article.content = it } + author?.let { article.author = it } + originLink?.let { article.originLink = it } + publishedAt?.let { article.publishedAt = it } + title?.let { article.title = it } + articleRepository.save(article) + val articleWithBoard = articleRepository.findByIdWithBoard(articleId) ?: throw ArticleNotFoundException() + return ArticleDto(articleWithBoard) + } + + fun delete(articleId: Long) { + val article = articleRepository.findByIdOrNull(articleId) ?: throw ArticleNotFoundException() + articleRepository.delete(article) + // 삭제시 기사 포린키로 갖고 있는 개체들 삭제 되었는지 확인 필요(나중에 개발시) + } +} diff --git a/src/main/kotlin/com/wafflestudio/team2server/board/BoardException.kt b/src/main/kotlin/com/wafflestudio/team2server/board/BoardException.kt new file mode 100644 index 0000000..de68e11 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/board/BoardException.kt @@ -0,0 +1,19 @@ +package com.wafflestudio.team2server.board + +import com.wafflestudio.team2server.DomainException +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +sealed class BoardException( + errorCode: Int, + httpStatusCode: HttpStatusCode, + msg: String, + cause: Throwable? = null, +) : DomainException(errorCode, httpStatusCode, msg, cause) + +class BoardNotFoundException : + BoardException( + errorCode = 0, + httpStatusCode = HttpStatus.NOT_FOUND, + msg = "Board not found", + ) diff --git a/src/main/kotlin/com/wafflestudio/team2server/board/dto/core/BoardDto.kt b/src/main/kotlin/com/wafflestudio/team2server/board/dto/core/BoardDto.kt new file mode 100644 index 0000000..fac4ed6 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/board/dto/core/BoardDto.kt @@ -0,0 +1,15 @@ +package com.wafflestudio.team2server.board.dto.core + +import com.wafflestudio.team2server.board.model.Board + +data class BoardDto( + val id: Long, + val name: String, + val sourceUrl: String, +) { + constructor(board: Board) : this( + id = board.id!!, + name = board.name, + sourceUrl = board.sourceUrl, + ) +} diff --git a/src/main/kotlin/com/wafflestudio/team2server/board/model/Board.kt b/src/main/kotlin/com/wafflestudio/team2server/board/model/Board.kt new file mode 100644 index 0000000..fcb6de7 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/board/model/Board.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.team2server.board.model + +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.Id +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant + +@Table("boards") +class Board( + @Id var id: Long? = null, + var name: String, + var sourceUrl: String, + @CreatedDate + var createdAt: Instant? = null, + @LastModifiedDate + var updatedAt: Instant? = null, +) diff --git a/src/main/kotlin/com/wafflestudio/team2server/board/repository/BoardRepository.kt b/src/main/kotlin/com/wafflestudio/team2server/board/repository/BoardRepository.kt new file mode 100644 index 0000000..cecf75f --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/team2server/board/repository/BoardRepository.kt @@ -0,0 +1,6 @@ +package com.wafflestudio.team2server.board.repository + +import com.wafflestudio.team2server.board.model.Board +import org.springframework.data.repository.ListCrudRepository + +interface BoardRepository : ListCrudRepository diff --git a/src/main/kotlin/com/wafflestudio/team2server/config/JacksonConfig.kt b/src/main/kotlin/com/wafflestudio/team2server/config/JacksonConfig.kt index b8e3008..0231e9d 100644 --- a/src/main/kotlin/com/wafflestudio/team2server/config/JacksonConfig.kt +++ b/src/main/kotlin/com/wafflestudio/team2server/config/JacksonConfig.kt @@ -1,12 +1,19 @@ package com.wafflestudio.team2server.config import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration class JacksonConfig { + // javatime 모듈 인식하도록 추가 @Bean - fun objectMapper(): ObjectMapper = ObjectMapper().registerKotlinModule() + fun objectMapper(): ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) } diff --git a/src/test/kotlin/com/wafflestudio/team2server/ArticleIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/team2server/ArticleIntegrationTest.kt new file mode 100644 index 0000000..c149e12 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/team2server/ArticleIntegrationTest.kt @@ -0,0 +1,284 @@ +package com.wafflestudio.team2server + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.team2server.article.dto.UpdateArticleRequest +import com.wafflestudio.team2server.article.dto.core.ArticleDto +import com.wafflestudio.team2server.article.dto.request.CreateArticleRequest +import com.wafflestudio.team2server.article.dto.response.ArticlePagingResponse +import com.wafflestudio.team2server.article.model.Article +import com.wafflestudio.team2server.article.service.ArticleService +import com.wafflestudio.team2server.board.model.Board +import com.wafflestudio.team2server.helper.DataGenerator +import com.wafflestudio.team2server.helper.QueryCounter +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@AutoConfigureMockMvc +class ArticleIntegrationTest + @Autowired + constructor( + private val dataGenerator: DataGenerator, + private val queryCounter: QueryCounter, + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val articleService: ArticleService, + ) { + @Test + fun `should create a article`() { + // given + val board = dataGenerator.generateBoard() + val request = + CreateArticleRequest( + title = "title", + content = "content", + author = "snu", + originLink = "https://example.com/article/123", + publishedAt = Instant.now(), + ) + + // when & then + mvc + .perform( + MockMvcRequestBuilders + .post("/api/v1/boards/${board.id!!}/articles") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value(request.title)) + .andExpect(jsonPath("$.content").value(request.content)) + .andExpect(jsonPath("$.author").value(request.author)) + .andExpect(jsonPath("$.originLink").value(request.originLink)) + } + + @Disabled + @Test + fun `should not create a with blank title`() { + // title이 blank이면 article을 생성할 수 없다. + // given + val board = dataGenerator.generateBoard() + val request = + CreateArticleRequest( + title = " ", // blank + content = "content", + author = "snu", + originLink = "https://example.com/article/123", + publishedAt = Instant.now(), + ) + + // when & then + mvc + .perform( + MockMvcRequestBuilders + .post("/api/v1/boards/${board.id!!}/articles") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)), + ).andExpect(status().isBadRequest) + } + + @Test + fun `should retrieve a single article`() { + // 게시글을 단건 조회 할 수 있다. + // given + val article = dataGenerator.generateArticle() + + // when & then + mvc + .perform( + MockMvcRequestBuilders.get("/api/v1/articles/${article.id}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(article.id)) + .andExpect(jsonPath("$.title").value(article.title)) + .andExpect(jsonPath("$.content").value(article.content)) + .andExpect(jsonPath("$.author").value(article.author)) + .andExpect(jsonPath("$.originLink").value(article.originLink)) + } + + @Test + fun `should update a article`() { + // 게시글 업데이트가 가능하다 + // given + val article = dataGenerator.generateArticle() + val request = + UpdateArticleRequest( + title = "sesese", + content = "uugg", + ) + // when & then + mvc + .perform( + MockMvcRequestBuilders + .patch("/api/v1/articles/${article.id}") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(article.id)) + .andExpect(jsonPath("$.title").value(request.title)) + .andExpect(jsonPath("$.content").value(request.content)) + .andExpect(jsonPath("$.author").value(article.author)) + } + + @Disabled + @Test + fun `should not update a article with blank title or content`() { + // blank 값으로는 게시글 없데이트가 불가하다. + // given + val article = dataGenerator.generateArticle() + val request = + UpdateArticleRequest( + title = "sesese", + content = " ", + ) + // when & then + mvc + .perform( + MockMvcRequestBuilders + .patch("/api/v1/articles/${article.id}") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)), + ).andExpect(status().isBadRequest) + } + + @Test + fun `should delete a article`() { + // 게시글 삭제가 가능하다 + // given + val article = dataGenerator.generateArticle() + // when & then + mvc + .perform( + MockMvcRequestBuilders.delete("/api/v1/articles/${article.id}"), + ).andExpect(status().isNoContent) + mvc + .perform( + MockMvcRequestBuilders.delete("/api/v1/articles/${article.id}"), + ).andExpect(status().isNotFound) + } + + @Test + fun `should paginate posts using published_at and id as cursor`() { + // publised_at과 id를 커서로 하여 게시판의 게시글을 페이지네이션 함 + // given + val board = dataGenerator.generateBoard() + repeat(30) { + dataGenerator.generateArticle(board = board) + } + // when & then + val response = + mvc + .perform( + MockMvcRequestBuilders.get("/api/v1/boards/${board.id!!}/articles?limit=15"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.paging.hasNext").value(true)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + mapper.readValue(it, ArticlePagingResponse::class.java) + } + assertArticlesAreSorted(response.data) + assertArticleAreInBoard(response.data, board) + + val nextResponse = + mvc + .perform( + MockMvcRequestBuilders + .get("/api/v1/boards/${board.id!!}/articles") + .param("limit", "15") + .param("nextPublishedAt", response.paging.nextPublishedAt!!.toString()) + .param("nextId", response.paging.nextId!!.toString()) + .accept(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.paging.hasNext").value(false)) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { mapper.readValue(it, ArticlePagingResponse::class.java) } + assertArticlesAreSorted(nextResponse.data) + assertArticleAreInBoard(nextResponse.data, board) + assertTrue((response.data.map { it.id } + nextResponse.data.map { it.id }).toSet().size == 30) + } + + @Test + fun `Only two queries are fired during pagination`() { + // 쿼리는 두번만 나간다 + // given + val board = dataGenerator.generateBoard() + val articles = + List(10) { + dataGenerator.generateArticle(board = board) + } + val response = + queryCounter.assertQueryCount(2) { + mvc + .perform( + MockMvcRequestBuilders.get("/api/v1/boards/${board.id!!}/articles?limit=20"), + ).andExpect(status().isOk) + .andReturn() + .response + .getContentAsString(Charsets.UTF_8) + .let { + mapper.readValue(it, ArticlePagingResponse::class.java) + } + } + assertArticlesAreSame(response.data, articles, board) + } + + private fun assertArticlesAreSorted(articles: List) { + if (articles.size <= 1) return + + articles.zipWithNext().forEach { (current, next) -> + assertTrue( + current.publishedAt >= next.publishedAt, + "Articles are not sorted by publishedAt DESC. Failed at id ${current.id} -> ${next.id}", + ) + + if (current.publishedAt == next.publishedAt) { + assertTrue( + current.id > next.id, + "Articles with same publishedAt are not sorted by id DESC. Failed at id ${current.id} -> ${next.id}", + ) + } + } + } + + private fun assertArticleAreInBoard( + posts: List, + board: Board, + ) { + posts.forEach { + assertTrue(it.board.id == board.id) + } + } + + private fun assertArticlesAreSame( + targetArticles: List, + originalArticles: List
, + board: Board, + ) { + val expected = + originalArticles + .map { ArticleDto(it, board) } + .sortedWith( + compareByDescending { it.publishedAt } + .thenByDescending { it.id }, + ) + + val expectedKeys = expected.map { Pair(it.board, it.id) } + val actualKeys = targetArticles.map { Pair(it.board, it.id) } + } + } diff --git a/src/test/kotlin/com/wafflestudio/team2server/helper/DataGenerator.kt b/src/test/kotlin/com/wafflestudio/team2server/helper/DataGenerator.kt index 0be5026..ebea335 100644 --- a/src/test/kotlin/com/wafflestudio/team2server/helper/DataGenerator.kt +++ b/src/test/kotlin/com/wafflestudio/team2server/helper/DataGenerator.kt @@ -1,10 +1,16 @@ package com.wafflestudio.team2server.helper +import com.wafflestudio.team2server.article.model.Article +import com.wafflestudio.team2server.article.repository.ArticleRepository +import com.wafflestudio.team2server.board.model.Board +import com.wafflestudio.team2server.board.repository.BoardRepository import com.wafflestudio.team2server.user.JwtProvider import com.wafflestudio.team2server.user.model.User import com.wafflestudio.team2server.user.repository.UserRepository import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.stereotype.Component +import java.time.Instant +import java.util.UUID import kotlin.random.Random @Component @@ -12,6 +18,8 @@ class DataGenerator( private val userRepository: UserRepository, private val jwtProvider: JwtProvider, private val bcryptPasswordEncoder: BCryptPasswordEncoder, + private val boardRepository: BoardRepository, + private val articleRepository: ArticleRepository, ) { fun generateUser( userId: String? = null, @@ -26,4 +34,40 @@ class DataGenerator( ) return user to jwtProvider.createToken(user.id!!) } + + fun generateBoard( + name: String? = null, + sorceUrl: String? = null, + ): Board { + val board = + boardRepository.save( + Board( + name = name ?: "board-${Random.nextInt(1000000)}", + sourceUrl = sorceUrl ?: "https://example.com/${UUID.randomUUID()}", + ), + ) + return board + } + + fun generateArticle( + title: String? = null, + content: String? = null, + board: Board? = null, + publishedAt: Instant = Instant.now(), + author: String? = null, + orginalLink: String? = null, + ): Article { + val article = + articleRepository.save( + Article( + title = title ?: "article-${Random.nextInt(1000000)}", + content = content ?: "content-${Random.nextInt(1000000)}", + author = author ?: "author-${Random.nextInt(1000000)}", + publishedAt = publishedAt, + originLink = orginalLink ?: "https://example.com/${UUID.randomUUID()}", + boardId = (board ?: generateBoard()).id!!, + ), + ) + return article + } } diff --git a/src/test/kotlin/com/wafflestudio/team2server/helper/QueryCounter.kt b/src/test/kotlin/com/wafflestudio/team2server/helper/QueryCounter.kt new file mode 100644 index 0000000..a4634ce --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/team2server/helper/QueryCounter.kt @@ -0,0 +1,97 @@ +package com.wafflestudio.team2server.helper + +import org.aopalliance.intercept.MethodInterceptor +import org.aopalliance.intercept.MethodInvocation +import org.assertj.core.api.Assertions.assertThat +import org.springframework.aop.framework.ProxyFactory +import org.springframework.beans.factory.config.BeanPostProcessor +import org.springframework.stereotype.Component +import java.sql.Connection +import java.sql.Statement +import javax.sql.DataSource + +@Component +class QueryCounter : BeanPostProcessor { + companion object { + private val queryCount = ThreadLocal.withInitial { 0L } + } + + override fun postProcessAfterInitialization( + bean: Any, + beanName: String, + ): Any = + if (bean is DataSource) { + ProxyFactory(bean) + .apply { + addAdvice(DataSourceInterceptor()) + }.proxy + } else { + bean + } + + fun assertQueryCount( + expectedCount: Long, + block: () -> R, + ): R { + clear() + val result = + try { + block() + } finally { + val actualCount = getCount() + clear() + + assertThat(actualCount) + .withFailMessage("\n[Query Count Mismatch]\nExpected: $expectedCount\nActual: $actualCount") + .isEqualTo(expectedCount) + } + return result + } + + private fun getCount(): Long = queryCount.get() + + private fun clear() = queryCount.remove() + + private class DataSourceInterceptor : MethodInterceptor { + override fun invoke(invocation: MethodInvocation): Any? { + val result = invocation.proceed() + return if (invocation.method.name == "getConnection" && result is Connection) { + createConnectionProxy(result) + } else { + result + } + } + + private fun createConnectionProxy(connection: Connection): Connection = + ProxyFactory(connection) + .apply { + addAdvice(ConnectionInterceptor()) + }.proxy as Connection + } + + private class ConnectionInterceptor : MethodInterceptor { + override fun invoke(invocation: MethodInvocation): Any? { + val result = invocation.proceed() + return if (result is Statement) { + createStatementProxy(result) + } else { + result + } + } + + private fun createStatementProxy(statement: Statement): Statement = + ProxyFactory(statement) + .apply { + addAdvice(StatementInterceptor()) + }.proxy as Statement + } + + private class StatementInterceptor : MethodInterceptor { + override fun invoke(invocation: MethodInvocation): Any? { + if (invocation.method.name.startsWith("execute")) { + queryCount.set(queryCount.get() + 1) + } + return invocation.proceed() + } + } +}