From bbbe5bee509f3c2d1395dccafe708b8a48756175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=84=A0=EA=B6=8C?= Date: Mon, 15 Jan 2024 16:16:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 골격 작성 * feat: 엔티티 작성 * feat: repository 작성 * feat: service 작성 * feat: response 작성 * feat: DB 조회 로직 작성 * feat: Jsoup 의존성 추가 * feat: 응답 내용 추가 1. article.is_notice 2. article.contentSummary 3. board.is_notice 4. board.children (must null) * feat: 예외처리 작성 * refactor: 페이징 관련 정보 클래스 분리 * fix: soft delete된 게시판 or 게시글은 조회 안되도록 수정 * fix: 1페이지 조회 안되는 버그 수정 * fix: 누락된 응답 추가 (summary) * fix: 최신글부터 조회하도록 수정 * test: 테스트 코드 작성 * move: ErrorResponseWrapper 위치 변경 * refactor: 코딩 컨벤션 준수 * refactor: 문자열 파싱 메서드 리팩토링 * test: 예외상황 테스트 추가 --- build.gradle | 1 + .../controller/CommunityController.java | 27 ++ .../community/dto/ArticlesResponse.java | 109 ++++++ .../exception/ArticleNotFoundException.java | 14 + .../koin/domain/community/model/Article.java | 127 ++++++ .../koin/domain/community/model/Board.java | 84 ++++ .../koin/domain/community/model/Criteria.java | 46 +++ .../repository/ArticleRepository.java | 14 + .../community/repository/BoardRepository.java | 13 + .../community/service/CommunityService.java | 42 ++ .../koin/global/exception/ErrorResponse.java | 7 + .../exception/GlobalExceptionHandler.java | 16 +- .../koin/acceptance/CommunityApiTest.java | 368 ++++++++++++++++++ 13 files changed, 865 insertions(+), 3 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/model/Article.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/model/Board.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/model/Criteria.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java create mode 100644 src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java diff --git a/build.gradle b/build.gradle index 40711ab88..607cada3e 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'com.mysql:mysql-connector-j' + implementation 'org.jsoup:jsoup:1.15.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java b/src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java new file mode 100644 index 000000000..2f879b630 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/controller/CommunityController.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.community.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.community.dto.ArticlesResponse; +import in.koreatech.koin.domain.community.service.CommunityService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class CommunityController { + + private final CommunityService communityService; + + @GetMapping("/articles") + public ResponseEntity getArticles( + @RequestParam Long boardId, + @RequestParam(required = false) Long page, + @RequestParam(required = false) Long limit + ) { + ArticlesResponse foundArticles = communityService.getArticles(boardId, page, limit); + return ResponseEntity.ok().body(foundArticles); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java b/src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java new file mode 100644 index 000000000..2d38f4099 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/dto/ArticlesResponse.java @@ -0,0 +1,109 @@ +package in.koreatech.koin.domain.community.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; + +public record ArticlesResponse( + List articles, + InnerBoardResponse board, + Long totalPage +) { + + public static ArticlesResponse of(List
articles, Board board, Long totalPage) { + return new ArticlesResponse( + articles.stream() + .map(InnerArticleResponse::from) + .toList(), + InnerBoardResponse.from(board), + totalPage + ); + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + private record InnerArticleResponse( + Long id, + Long boardId, + String title, + String content, + Long userId, + String nickname, + Long hit, String ip, + Boolean isSolved, + Boolean isDeleted, + Byte commentCount, + String meta, + Boolean isNotice, + Long noticeArticleId, + String summary, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt, + @JsonProperty("contentSummary") String contentSummary + ) { + + public static InnerArticleResponse from(Article article) { + return new InnerArticleResponse( + article.getId(), + article.getBoardId(), + article.getTitle(), + article.getContent(), + article.getUserId(), + article.getNickname(), + article.getHit(), + article.getIp(), + article.getIsSolved(), + article.getIsDeleted(), + article.getCommentCount(), + article.getMeta(), + article.getIsNotice(), + article.getNoticeArticleId(), + article.getSummary(), + article.getCreatedAt(), + article.getUpdatedAt(), + article.getContentSummary() + ); + } + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + public record InnerBoardResponse( + Long id, + String tag, + String name, + Boolean isAnonymous, + Long articleCount, + Boolean isDeleted, + Boolean isNotice, + Long parentId, + Long seq, + List children, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + public static InnerBoardResponse from(Board board) { + return new InnerBoardResponse( + board.getId(), + board.getTag(), + board.getName(), + board.getIsAnonymous(), + board.getArticleCount(), + board.getIsDeleted(), + board.getIsNotice(), + board.getParentId(), + board.getSeq(), + board.getChildren().isEmpty() + ? null : board.getChildren().stream().map(InnerBoardResponse::from).toList(), + board.getCreatedAt(), + board.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java b/src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java new file mode 100644 index 000000000..ac1c49dd5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/exception/ArticleNotFoundException.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.community.exception; + +public class ArticleNotFoundException extends RuntimeException { + private static final String DEFAULT_MESSAGE = "게시글이 존재하지 않습니다."; + + public ArticleNotFoundException(String message) { + super(message); + } + + public static ArticleNotFoundException withDetail(String detail) { + String message = String.format("%s %s", DEFAULT_MESSAGE, detail); + return new ArticleNotFoundException(message); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/Article.java b/src/main/java/in/koreatech/koin/domain/community/model/Article.java new file mode 100644 index 000000000..ab00a5f68 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/Article.java @@ -0,0 +1,127 @@ +package in.koreatech.koin.domain.community.model; + +import org.hibernate.annotations.Where; +import org.jsoup.Jsoup; + +import in.koreatech.koin.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "articles") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Article extends BaseEntity { + + private static final int SUMMARY_MIN_LENGTH = 0; + private static final int SUMMARY_MAX_LENGTH = 100; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @Column(name = "board_id", nullable = false) + private Long boardId; + + @Size(max = 255) + @NotNull + @Column(name = "title", nullable = false) + private String title; + + @NotNull + @Lob + @Column(name = "content", nullable = false) + private String content; + + @NotNull + @Column(name = "user_id", nullable = false) + private Long userId; + + @Size(max = 50) + @NotNull + @Column(name = "nickname", nullable = false, length = 50) + private String nickname; + + @NotNull + @Column(name = "hit", nullable = false) + private Long hit; + + @Size(max = 45) + @NotNull + @Column(name = "ip", nullable = false, length = 45) + private String ip; + + @NotNull + @Column(name = "is_solved", nullable = false) + private Boolean isSolved = false; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @NotNull + @Column(name = "comment_count", nullable = false) + private Byte commentCount; + + @Lob + @Column(name = "meta") + private String meta; + + @NotNull + @Column(name = "is_notice", nullable = false) + private Boolean isNotice = false; + + @Column(name = "notice_article_id") + private Long noticeArticleId; + + @Transient + private String summary; + + public String getContentSummary() { + if (content == null) { + return ""; + } + String contentSummary = Jsoup.parse(content).text(); + contentSummary = contentSummary.replace(" ", "").strip(); + if (contentSummary.length() < SUMMARY_MAX_LENGTH) { + return contentSummary; + } + return contentSummary.substring(SUMMARY_MIN_LENGTH, SUMMARY_MAX_LENGTH); + } + + @Builder + private Article(Long boardId, String title, String content, Long userId, String nickname, Long hit, + String ip, Boolean isSolved, Boolean isDeleted, Byte commentCount, String meta, Boolean isNotice, + Long noticeArticleId) { + this.boardId = boardId; + this.title = title; + this.content = content; + this.userId = userId; + this.nickname = nickname; + this.hit = hit; + this.ip = ip; + this.isSolved = isSolved; + this.isDeleted = isDeleted; + this.commentCount = commentCount; + this.meta = meta; + this.isNotice = isNotice; + this.noticeArticleId = noticeArticleId; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/Board.java b/src/main/java/in/koreatech/koin/domain/community/model/Board.java new file mode 100644 index 000000000..554c4ea4b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/Board.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.community.model; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "boards") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Board extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Size(max = 10) + @NotNull + @Column(name = "tag", nullable = false, length = 10) + private String tag; + + @Size(max = 50) + @NotNull + @Column(name = "name", nullable = false, length = 50) + private String name; + + @NotNull + @Column(name = "is_anonymous", nullable = false) + private Boolean isAnonymous = false; + + @NotNull + @Column(name = "article_count", nullable = false) + private Long articleCount; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @NotNull + @Column(name = "is_notice", nullable = false) + private Boolean isNotice = false; + + @Column(name = "parent_id") + private Long parentId; + + @NotNull + @Column(name = "seq", nullable = false) + private Long seq; + + public List getChildren() { + return new ArrayList<>(); + } + + @Builder + private Board(String tag, String name, Boolean isAnonymous, Long articleCount, Boolean isDeleted, + Boolean isNotice, Long parentId, Long seq) { + this.tag = tag; + this.name = name; + this.isAnonymous = isAnonymous; + this.articleCount = articleCount; + this.isDeleted = isDeleted; + this.isNotice = isNotice; + this.parentId = parentId; + this.seq = seq; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/model/Criteria.java b/src/main/java/in/koreatech/koin/domain/community/model/Criteria.java new file mode 100644 index 000000000..f5dde5aef --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/model/Criteria.java @@ -0,0 +1,46 @@ +package in.koreatech.koin.domain.community.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class Criteria { + private static final Long DEFAULT_PAGE = 1L; + private static final Long MIN_PAGE = 1L; + + private static final Long DEFAULT_LIMIT = 10L; + private static final Long MIN_LIMIT = 1L; + private static final Long MAX_LIMIT = 50L; + + private final int page; + private final int limit; + + public static Criteria of(Long page, Long limit) { + return new Criteria(validatePage(page), validateLimit(limit)); + } + + private static int validatePage(Long page) { + if (page == null) { + page = DEFAULT_PAGE; + } + if (page < MIN_PAGE) { + page = MIN_PAGE; + } + page -= 1; // start from 0 + return page.intValue(); + } + + private static int validateLimit(Long limit) { + if (limit == null) { + limit = DEFAULT_LIMIT; + } + if (limit < MIN_LIMIT) { + limit = MIN_LIMIT; + } + if (limit > MAX_LIMIT) { + limit = MAX_LIMIT; + } + return limit.intValue(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java new file mode 100644 index 000000000..96f0ac03f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/repository/ArticleRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.community.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.model.Article; + +public interface ArticleRepository extends Repository { + + Page
findByBoardId(Long boardId, Pageable pageable); + + Article save(Article article); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java b/src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java new file mode 100644 index 000000000..cf288a4ad --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/repository/BoardRepository.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.domain.community.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.community.model.Board; + +public interface BoardRepository extends Repository { + Optional findById(Long id); + + Board save(Board board); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java b/src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java new file mode 100644 index 000000000..624b0c57a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/service/CommunityService.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.community.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.community.dto.ArticlesResponse; +import in.koreatech.koin.domain.community.exception.ArticleNotFoundException; +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.model.Criteria; +import in.koreatech.koin.domain.community.repository.ArticleRepository; +import in.koreatech.koin.domain.community.repository.BoardRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommunityService { + + private final ArticleRepository articleRepository; + private final BoardRepository boardRepository; + + public static final Sort SORT_ORDER_BY = Sort.by(Sort.Direction.DESC, "id"); + + public ArticlesResponse getArticles(Long boardId, Long page, Long limit) { + Criteria criteria = Criteria.of(page, limit); + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> ArticleNotFoundException.withDetail( + "boardId: " + boardId + ", page: " + criteria.getPage() + ", limit: " + criteria.getLimit())); + PageRequest pageRequest = PageRequest.of(criteria.getPage(), criteria.getLimit(), SORT_ORDER_BY); + Page
articles = articleRepository.findByBoardId(boardId, pageRequest); + if (articles.getContent().isEmpty()) { + throw ArticleNotFoundException.withDetail( + "boardId: " + boardId + ", page: " + criteria.getPage() + ", limit: " + criteria.getLimit()); + } + + return ArticlesResponse.of(articles.getContent(), board, (long)articles.getTotalPages()); + } +} diff --git a/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java b/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java index ae066867d..dedb3aa98 100644 --- a/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java +++ b/src/main/java/in/koreatech/koin/global/exception/ErrorResponse.java @@ -20,4 +20,11 @@ public static ErrorResponse of(int code, String message) { public static ErrorResponse from(String message) { return new ErrorResponse(0, message); } + + public record ErrorResponseWrapper(ErrorResponse error) { + + public static ErrorResponseWrapper from(ErrorResponse error) { + return new ErrorResponseWrapper(error); + } + } } diff --git a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java index 05875f41a..17b4be846 100644 --- a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java @@ -1,14 +1,17 @@ package in.koreatech.koin.global.exception; -import in.koreatech.koin.domain.auth.exception.AuthException; -import in.koreatech.koin.domain.user.exception.UserNotFoundException; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import in.koreatech.koin.domain.auth.exception.AuthException; +import in.koreatech.koin.domain.community.exception.ArticleNotFoundException; +import in.koreatech.koin.domain.user.exception.UserNotFoundException; +import in.koreatech.koin.global.exception.ErrorResponse.ErrorResponseWrapper; +import lombok.extern.slf4j.Slf4j; + @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -38,4 +41,11 @@ public ResponseEntity handleAuthException(AuthException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ErrorResponse.from("잘못된 인증정보입니다.")); } + + @ExceptionHandler + public ResponseEntity handleArticleNotFoundException(ArticleNotFoundException e) { + log.warn(e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponseWrapper.from(ErrorResponse.from("There is no article"))); + } } diff --git a/src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java b/src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java new file mode 100644 index 000000000..dd3640d06 --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/CommunityApiTest.java @@ -0,0 +1,368 @@ +package in.koreatech.koin.acceptance; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.community.model.Article; +import in.koreatech.koin.domain.community.model.Board; +import in.koreatech.koin.domain.community.repository.ArticleRepository; +import in.koreatech.koin.domain.community.repository.BoardRepository; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +class CommunityApiTest extends AcceptanceTest { + + @Autowired + private ArticleRepository articleRepository; + + @Autowired + private BoardRepository boardRepository; + + private final long PAGE_NUMBER = 1L; + private final long PAGE_LIMIT = 1L; + private final long ARTICLE_COUNT = 2L; + + private Board board; + private Article article1, article2; + + @BeforeEach + void givenBeforeEach() { + board = Board.builder() + .tag("FA001") + .name("자유게시판") + .isAnonymous(false) + .articleCount(338L) + .isDeleted(false) + .isNotice(false) + .parentId(null) + .seq(1L) + .build(); + + article1 = Article.builder() + .boardId(1L) + .title("제목") + .content("

내용

") + .userId(1L) + .nickname("BCSD") + .hit(14L) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build(); + + article2 = Article.builder() + .boardId(1L) + .title("TITLE") + .content("

CONTENT

") + .userId(1L) + .nickname("BCSD") + .hit(14L) + .ip("123.14.321.213") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build(); + + boardRepository.save(board); + articleRepository.save(article1); + articleRepository.save(article2); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다.") + void getArticlesByPagination() { + // given + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER) + .param("limit", PAGE_LIMIT) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("board.id")).isEqualTo(board.getId()); + softly.assertThat(response.jsonPath().getString("board.tag")).isEqualTo(board.getTag()); + softly.assertThat(response.jsonPath().getString("board.name")).isEqualTo(board.getName()); + softly.assertThat(response.jsonPath().getBoolean("board.is_anonymous")).isEqualTo(board.getIsAnonymous()); + softly.assertThat(response.jsonPath().getLong("board.article_count")).isEqualTo(board.getArticleCount()); + softly.assertThat(response.jsonPath().getBoolean("board.is_deleted")).isEqualTo(board.getIsDeleted()); + softly.assertThat(response.jsonPath().getBoolean("board.is_notice")).isEqualTo(board.getIsNotice()); + softly.assertThat(response.jsonPath().getString("board.parent_id")).isEqualTo(board.getParentId()); + softly.assertThat(response.jsonPath().getLong("board.seq")).isEqualTo(board.getSeq()); + softly.assertThat(response.jsonPath().getString("board.children")).isEqualTo(board.getChildren().isEmpty() ? null : board.getChildren()); + + softly.assertThat(response.jsonPath().getLong("articles[0].id")).isEqualTo(article2.getId()); + softly.assertThat(response.jsonPath().getLong("articles[0].board_id")).isEqualTo(article2.getBoardId()); + softly.assertThat(response.jsonPath().getString("articles[0].title")).isEqualTo(article2.getTitle()); + softly.assertThat(response.jsonPath().getString("articles[0].content")).isEqualTo(article2.getContent()); + softly.assertThat(response.jsonPath().getLong("articles[0].user_id")).isEqualTo(article2.getUserId()); + softly.assertThat(response.jsonPath().getString("articles[0].nickname")).isEqualTo(article2.getNickname()); + softly.assertThat(response.jsonPath().getLong("articles[0].hit")).isEqualTo(article2.getHit()); + softly.assertThat(response.jsonPath().getString("articles[0].ip")).isEqualTo(article2.getIp()); + softly.assertThat(response.jsonPath().getBoolean("articles[0].is_solved")).isEqualTo(article2.getIsSolved()); + softly.assertThat(response.jsonPath().getBoolean("articles[0].is_deleted")).isEqualTo(article2.getIsDeleted()); + softly.assertThat(response.jsonPath().getByte("articles[0].comment_count")).isEqualTo(article2.getCommentCount()); + softly.assertThat(response.jsonPath().getString("articles[0].meta")).isEqualTo(article2.getMeta()); + softly.assertThat(response.jsonPath().getBoolean("articles[0].is_notice")).isEqualTo(article2.getIsNotice()); + softly.assertThat(response.jsonPath().getString("articles[0].notice_article_id")).isEqualTo(article2.getNoticeArticleId()); + softly.assertThat(response.jsonPath().getString("articles[0].summary")).isEqualTo(article2.getSummary()); + softly.assertThat(response.jsonPath().getString("articles[0].contentSummary")).isEqualTo(article2.getContentSummary()); + + softly.assertThat(response.jsonPath().getLong("totalPage")).isEqualTo(ARTICLE_COUNT / PAGE_LIMIT); + } + ); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지가 0이면 1 페이지 조회") + void getArticlesByPagination_0Page() { + // given + final long PAGE_NUMBER_ZERO = 0L; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER_ZERO) + .param("limit", PAGE_LIMIT) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("articles[0].id")).isEqualTo(article2.getId()); + } + ); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지가 음수이면 1 페이지 조회") + void getArticlesByPagination_lessThan0Pages() { + // given + final long PAGE_NUMBER_MINUS = -10L; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER_MINUS) + .param("limit", PAGE_LIMIT) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("articles[0].id")).isEqualTo(article2.getId()); + } + ); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - limit가 0 이면 한 번에 1 게시글 조회") + void getArticlesByPagination_1Limit() { + // given + final long PAGE_LIMIT_ZERO = 0L; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER) + .param("limit", PAGE_LIMIT_ZERO) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("totalPage")).isEqualTo(ARTICLE_COUNT / PAGE_LIMIT); + } + ); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - limit가 음수이면 한 번에 1 게시글 조회") + void getArticlesByPagination_lessThan0Limit() { + // given + final long PAGE_LIMIT_ZERO = -10L; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER) + .param("limit", PAGE_LIMIT_ZERO) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("totalPage")).isEqualTo(ARTICLE_COUNT / PAGE_LIMIT); + } + ); + } + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - limit가 50 이상이면 한 번에 50 게시글 조회") + void getArticlesByPagination_over50Limit() { + // given + final long PAGE_LIMIT_ZERO = 100L; + final long MAX_PAGE_LIMIT = 50L; + final long ADD_ARTICLE_COUNT = 60L; + + for (int i = 0; i < ADD_ARTICLE_COUNT; i++) { + Article article = Article.builder() + .boardId(1L) + .title("제목") + .content("

내용

") + .userId(1L) + .nickname("BCSD") + .hit(14L) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build(); + articleRepository.save(article); + }; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER) + .param("limit", PAGE_LIMIT_ZERO) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("totalPage")).isEqualTo((long)Math.ceil(((double)ARTICLE_COUNT + ADD_ARTICLE_COUNT) / MAX_PAGE_LIMIT)); + } + ); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지, limit가 주어지지 않으면 1 페이지 10 게시글 조회") + void getArticlesByPagination_default() { + // given + final long DEFAULT_LIMIT = 10L; + final long ADD_ARTICLE_COUNT = 10L; + final long FINAL_ARTICLE_ID = ARTICLE_COUNT + ADD_ARTICLE_COUNT; + + for (int i = 0; i < ADD_ARTICLE_COUNT; i++) { + Article article = Article.builder() + .boardId(1L) + .title("제목") + .content("

내용

") + .userId(1L) + .nickname("BCSD") + .hit(14L) + .ip("123.21.234.321") + .isSolved(false) + .isDeleted(false) + .commentCount((byte)2) + .meta(null) + .isNotice(false) + .noticeArticleId(null) + .build(); + articleRepository.save(article); + }; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getLong("articles[0].id")).isEqualTo(FINAL_ARTICLE_ID); + softly.assertThat(response.jsonPath().getLong("totalPage")).isEqualTo((long)Math.ceil(((double)ARTICLE_COUNT + ADD_ARTICLE_COUNT) / DEFAULT_LIMIT)); + } + ); + } + + @Test + @DisplayName("게시글들을 페이지네이션하여 조회한다. - 페이지가 최대 페이지 수를 넘어가면 404") + void getArticlesByPagination_overMaxPageNotFound() { + // given + final long PAGE_NUMBER = 10000L; + + // when then + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .param("boardId", board.getId()) + .param("page", PAGE_NUMBER) + .param("limit", PAGE_LIMIT) + .get("/articles") + .then() + .log().all() + .statusCode(HttpStatus.NOT_FOUND.value()) + .extract(); + } +}