diff --git a/api-module/src/main/java/hongik/triple/apimodule/application/board/BoardService.java b/api-module/src/main/java/hongik/triple/apimodule/application/board/BoardService.java new file mode 100644 index 0000000..6bdddb2 --- /dev/null +++ b/api-module/src/main/java/hongik/triple/apimodule/application/board/BoardService.java @@ -0,0 +1,118 @@ +package hongik.triple.apimodule.application.board; + +import hongik.triple.commonmodule.dto.board.BoardReq; +import hongik.triple.commonmodule.dto.board.BoardRes; +import hongik.triple.domainmodule.domain.board.Board; +import hongik.triple.domainmodule.domain.board.repository.BoardRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BoardService { + + private final BoardRepository boardRepository; + + /** + * 공지사항 등록 + */ + @Transactional + public BoardRes registerBoard(BoardReq request) { + // Validation + validateBoardReq(request); + + // Business Logic + Board board = Board.builder() + .title(request.title()) + .content(request.content()) + .build(); + + Board savedBoard = boardRepository.save(board); + + // Response + return convertToBoardRes(savedBoard); + } + + /** + * 공지사항 수정 + */ + @Transactional + public BoardRes updateBoard(Long boardId, BoardReq request) { + // Validation + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new IllegalArgumentException("Board not found with id: " + boardId)); + + validateBoardReq(request); + + // Business Logic + board.update(request.title(), request.content()); + + // Response + return convertToBoardRes(board); + } + + /** + * 공지사항 삭제 + */ + @Transactional + public void deleteBoard(Long boardId) { + // Validation + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new IllegalArgumentException("Board not found with id: " + boardId)); + + // Business Logic + boardRepository.delete(board); + } + + /** + * 공지사항 단일 조회 + */ + public BoardRes getBoard(Long boardId) { + // Validation + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new IllegalArgumentException("Board not found with id: " + boardId)); + + // Response + return convertToBoardRes(board); + } + + /** + * 공지사항 페이지네이션 조회 + */ + public Page getBoardList(Pageable pageable) { + // Business Logic + Page boardPage = boardRepository.findAllByOrderByCreatedAtDesc(pageable); + + // Response + return boardPage.map(this::convertToBoardRes); + } + + /** + * BoardReq 유효성 검증 + */ + private void validateBoardReq(BoardReq request) { + if (request.title() == null || request.title().trim().isEmpty()) { + throw new IllegalArgumentException("Title cannot be empty"); + } + if (request.content() == null || request.content().trim().isEmpty()) { + throw new IllegalArgumentException("Content cannot be empty"); + } + } + + /** + * Board 엔티티를 BoardRes Record로 변환 + */ + private BoardRes convertToBoardRes(Board board) { + return new BoardRes( + board.getBoardId(), + board.getTitle(), + board.getContent(), + board.getCreatedAt(), + board.getModifiedAt() + ); + } +} diff --git a/api-module/src/main/java/hongik/triple/apimodule/presentation/board/BoardController.java b/api-module/src/main/java/hongik/triple/apimodule/presentation/board/BoardController.java new file mode 100644 index 0000000..e3c3515 --- /dev/null +++ b/api-module/src/main/java/hongik/triple/apimodule/presentation/board/BoardController.java @@ -0,0 +1,109 @@ +package hongik.triple.apimodule.presentation.board; + +import hongik.triple.apimodule.application.board.BoardService; +import hongik.triple.apimodule.global.common.ApplicationResponse; +import hongik.triple.apimodule.global.security.PrincipalDetails; +import hongik.triple.commonmodule.dto.analysis.AnalysisRes; +import hongik.triple.commonmodule.dto.board.BoardReq; +import hongik.triple.commonmodule.dto.board.BoardRes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +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 lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/board") +@RequiredArgsConstructor +@Tag(name = "Board", description = "공지사항 관련 API") +public class BoardController { + + private final BoardService boardService; + + @PostMapping("/register") + @Operation(summary = "공지사항 등록", description = "공지사항을 등록합니다. (제목, 본문 두 가지 형태만 있으며, 이미지는 등록할 수 없습니다.)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "공지사항 등록 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = BoardRes.class))), + @ApiResponse(responseCode = "400", + description = "잘못된 요청"), + @ApiResponse(responseCode = "500", + description = "서버 오류") + }) + public ApplicationResponse registerBoard(@RequestBody BoardReq request) { + BoardRes response = boardService.registerBoard(request); + return ApplicationResponse.ok(response); + } + + @PutMapping("/{boardId}") + @Operation(summary = "공지사항 수정", description = "특정 공지사항을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "공지사항 수정 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = BoardRes.class))), + @ApiResponse(responseCode = "404", + description = "공지사항을 찾을 수 없음"), + @ApiResponse(responseCode = "500", + description = "서버 오류") + }) + public ApplicationResponse updateBoard(@PathVariable Long boardId, @RequestBody BoardReq request) { + BoardRes response = boardService.updateBoard(boardId, request); + return ApplicationResponse.ok(response); + } + + @DeleteMapping("/{boardId}") + @Operation(summary = "공지사항 삭제", description = "특정 공지사항을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "공지사항 삭제 성공"), + @ApiResponse(responseCode = "404", + description = "공지사항을 찾을 수 없음"), + @ApiResponse(responseCode = "500", + description = "서버 오류") + }) + public ApplicationResponse deleteBoard(@PathVariable Long boardId) { + boardService.deleteBoard(boardId); + return ApplicationResponse.ok("공지사항이 삭제되었습니다."); + } + + @GetMapping("/{boardId}") + @Operation(summary = "공지사항 단일 조회", description = "특정 공지사항을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "공지사항 조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = BoardRes.class))), + @ApiResponse(responseCode = "404", + description = "공지사항을 찾을 수 없음"), + @ApiResponse(responseCode = "500", + description = "서버 오류") + }) + public ApplicationResponse getBoard(@PathVariable Long boardId) { + BoardRes response = boardService.getBoard(boardId); + return ApplicationResponse.ok(response); + } + + @GetMapping("/list") + @Operation(summary = "공지사항 목록 조회", description = "공지사항 목록을 페이지네이션으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "공지사항 목록 조회 성공"), + @ApiResponse(responseCode = "500", + description = "서버 오류") + }) + public ApplicationResponse getBoardList( + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page response = boardService.getBoardList(pageable); + return ApplicationResponse.ok(response); + } +} diff --git a/api-module/src/test/java/hongik/triple/apimodule/board/BoardServiceTest.java b/api-module/src/test/java/hongik/triple/apimodule/board/BoardServiceTest.java new file mode 100644 index 0000000..11102ac --- /dev/null +++ b/api-module/src/test/java/hongik/triple/apimodule/board/BoardServiceTest.java @@ -0,0 +1,409 @@ +package hongik.triple.apimodule.board; + +import hongik.triple.apimodule.application.board.BoardService; +import hongik.triple.commonmodule.dto.board.BoardReq; +import hongik.triple.commonmodule.dto.board.BoardRes; +import hongik.triple.domainmodule.domain.board.Board; +import hongik.triple.domainmodule.domain.board.repository.BoardRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + + +@DisplayName("BoardService 테스트") +@ExtendWith(MockitoExtension.class) +public class BoardServiceTest { + + @Mock + private BoardRepository boardRepository; + + @InjectMocks + private BoardService boardService; + + @Nested + @DisplayName("공지사항 등록") + class RegisterBoard { + + @Test + @DisplayName("성공") + void success() { + // given + Long boardId = 1L; + BoardReq request = new BoardReq("테스트 제목", "테스트 내용"); + + Board board = Board.builder() + .title("테스트 제목") + .content("테스트 내용") + .build(); + ReflectionTestUtils.setField(board, "boardId", boardId); + + given(boardRepository.save(any(Board.class))).willReturn(board); + + // when + BoardRes response = boardService.registerBoard(request); + + // then + assertThat(response).isNotNull(); + assertThat(response.boardId()).isEqualTo(1L); + assertThat(response.title()).isEqualTo("테스트 제목"); + assertThat(response.content()).isEqualTo("테스트 내용"); + + verify(boardRepository, times(1)).save(any(Board.class)); + } + + @Test + @DisplayName("실패 - 제목이 null") + void fail_TitleIsNull() { + // given + BoardReq request = new BoardReq(null, "테스트 내용"); + + // when & then + assertThatThrownBy(() -> boardService.registerBoard(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Title cannot be empty"); + + verify(boardRepository, never()).save(any(Board.class)); + } + + @Test + @DisplayName("실패 - 제목이 빈 문자열") + void fail_TitleIsEmpty() { + // given + BoardReq request = new BoardReq(" ", "테스트 내용"); + + // when & then + assertThatThrownBy(() -> boardService.registerBoard(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Title cannot be empty"); + + verify(boardRepository, never()).save(any(Board.class)); + } + + @Test + @DisplayName("실패 - 내용이 null") + void fail_ContentIsNull() { + // given + BoardReq request = new BoardReq("테스트 제목", null); + + // when & then + assertThatThrownBy(() -> boardService.registerBoard(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Content cannot be empty"); + + verify(boardRepository, never()).save(any(Board.class)); + } + + @Test + @DisplayName("실패 - 내용이 빈 문자열") + void fail_ContentIsEmpty() { + // given + BoardReq request = new BoardReq("테스트 제목", " "); + + // when & then + assertThatThrownBy(() -> boardService.registerBoard(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Content cannot be empty"); + + verify(boardRepository, never()).save(any(Board.class)); + } + } + + @Nested + @DisplayName("공지사항 수정") + class UpdateBoard { + + @Test + @DisplayName("성공") + void success() { + // given + Long boardId = 1L; + BoardReq request = new BoardReq("수정된 제목", "수정된 내용"); + + Board board = Board.builder() + .title("원래 제목") + .content("원래 내용") + .build(); + ReflectionTestUtils.setField(board, "boardId", boardId); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardRes response = boardService.updateBoard(boardId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.boardId()).isEqualTo(boardId); + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(response.content()).isEqualTo("수정된 내용"); + + verify(boardRepository, times(1)).findById(boardId); + } + + @Test + @DisplayName("실패 - 존재하지 않는 게시글") + void fail_NotFound() { + // given + Long boardId = 999L; + BoardReq request = new BoardReq("수정된 제목", "수정된 내용"); + + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> boardService.updateBoard(boardId, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Board not found with id: " + boardId); + + verify(boardRepository, times(1)).findById(boardId); + } + + @Test + @DisplayName("실패 - 제목이 null") + void fail_TitleIsNull() { + // given + Long boardId = 1L; + BoardReq request = new BoardReq(null, "수정된 내용"); + + Board board = Board.builder() + .title("원래 제목") + .content("원래 내용") + .build(); + ReflectionTestUtils.setField(board, "boardId", boardId); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when & then + assertThatThrownBy(() -> boardService.updateBoard(boardId, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Title cannot be empty"); + + verify(boardRepository, times(1)).findById(boardId); + } + + @Test + @DisplayName("실패 - 내용이 null") + void fail_ContentIsNull() { + // given + Long boardId = 1L; + BoardReq request = new BoardReq("수정된 제목", null); + + Board board = Board.builder() + .title("원래 제목") + .content("원래 내용") + .build(); + ReflectionTestUtils.setField(board, "boardId", boardId); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when & then + assertThatThrownBy(() -> boardService.updateBoard(boardId, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Content cannot be empty"); + + verify(boardRepository, times(1)).findById(boardId); + } + } + + @Nested + @DisplayName("공지사항 삭제") + class DeleteBoard { + + @Test + @DisplayName("성공") + void success() { + // given + Long boardId = 1L; + + Board board = Board.builder() + .title("테스트 제목") + .content("테스트 내용") + .build(); + ReflectionTestUtils.setField(board, "boardId", boardId); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + doNothing().when(boardRepository).delete(board); + + // when + boardService.deleteBoard(boardId); + + // then + verify(boardRepository, times(1)).findById(boardId); + verify(boardRepository, times(1)).delete(board); + } + + @Test + @DisplayName("실패 - 존재하지 않는 게시글") + void fail_NotFound() { + // given + Long boardId = 999L; + + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> boardService.deleteBoard(boardId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Board not found with id: " + boardId); + + verify(boardRepository, times(1)).findById(boardId); + verify(boardRepository, never()).delete(any(Board.class)); + } + } + + @Nested + @DisplayName("공지사항 단일 조회") + class GetBoard { + + @Test + @DisplayName("성공") + void success() { + // given + Long boardId = 1L; + + Board board = Board.builder() + .title("테스트 제목") + .content("테스트 내용") + .build(); + ReflectionTestUtils.setField(board, "boardId", boardId); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardRes response = boardService.getBoard(boardId); + + // then + assertThat(response).isNotNull(); + assertThat(response.boardId()).isEqualTo(boardId); + assertThat(response.title()).isEqualTo("테스트 제목"); + assertThat(response.content()).isEqualTo("테스트 내용"); + + verify(boardRepository, times(1)).findById(boardId); + } + + @Test + @DisplayName("실패 - 존재하지 않는 게시글") + void fail_NotFound() { + // given + Long boardId = 999L; + + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> boardService.getBoard(boardId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Board not found with id: " + boardId); + + verify(boardRepository, times(1)).findById(boardId); + } + } + + @Nested + @DisplayName("공지사항 목록 조회") + class GetBoardList { + + @Test + @DisplayName("성공 - 데이터가 있는 경우") + void success_WithData() { + // given + Pageable pageable = PageRequest.of(0, 10); + + Board board1 = Board.builder() + .title("제목1") + .content("내용1") + .build(); + ReflectionTestUtils.setField(board1, "boardId", 1L); + + Board board2 = Board.builder() + .title("제목2") + .content("내용2") + .build(); + ReflectionTestUtils.setField(board2, "boardId", 2L); + + List boards = List.of(board1, board2); + Page boardPage = new PageImpl<>(boards, pageable, boards.size()); + + given(boardRepository.findAllByOrderByCreatedAtDesc(pageable)).willReturn(boardPage); + + // when + Page response = boardService.getBoardList(pageable); + + // then + assertThat(response).isNotNull(); + assertThat(response.getContent()).hasSize(2); + assertThat(response.getContent().get(0).boardId()).isEqualTo(1L); + assertThat(response.getContent().get(0).title()).isEqualTo("제목1"); + assertThat(response.getContent().get(1).boardId()).isEqualTo(2L); + assertThat(response.getContent().get(1).title()).isEqualTo("제목2"); + assertThat(response.getTotalElements()).isEqualTo(2); + + verify(boardRepository, times(1)).findAllByOrderByCreatedAtDesc(pageable); + } + + @Test + @DisplayName("성공 - 데이터가 없는 경우") + void success_NoData() { + // given + Pageable pageable = PageRequest.of(0, 10); + + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + + given(boardRepository.findAllByOrderByCreatedAtDesc(pageable)).willReturn(emptyPage); + + // when + Page response = boardService.getBoardList(pageable); + + // then + assertThat(response).isNotNull(); + assertThat(response.getContent()).isEmpty(); + assertThat(response.getTotalElements()).isEqualTo(0); + + verify(boardRepository, times(1)).findAllByOrderByCreatedAtDesc(pageable); + } + + @Test + @DisplayName("성공 - 페이지네이션 확인") + void success_Pagination() { + // given + Pageable pageable = PageRequest.of(1, 5); // 2번째 페이지, 5개씩 + + Board board = Board.builder() + .title("제목6") + .content("내용6") + .build(); + ReflectionTestUtils.setField(board, "boardId", 6L); + + List boards = List.of(board); + Page boardPage = new PageImpl<>(boards, pageable, 10); // 전체 10개 + + given(boardRepository.findAllByOrderByCreatedAtDesc(pageable)).willReturn(boardPage); + + // when + Page response = boardService.getBoardList(pageable); + + // then + assertThat(response).isNotNull(); + assertThat(response.getContent()).hasSize(1); + assertThat(response.getTotalElements()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + assertThat(response.getNumber()).isEqualTo(1); // 현재 페이지 + + verify(boardRepository, times(1)).findAllByOrderByCreatedAtDesc(pageable); + } + } +} diff --git a/common-module/src/main/java/hongik/triple/commonmodule/dto/board/BoardReq.java b/common-module/src/main/java/hongik/triple/commonmodule/dto/board/BoardReq.java new file mode 100644 index 0000000..4814aae --- /dev/null +++ b/common-module/src/main/java/hongik/triple/commonmodule/dto/board/BoardReq.java @@ -0,0 +1,7 @@ +package hongik.triple.commonmodule.dto.board; + +public record BoardReq( + String title, + String content +) { +} diff --git a/common-module/src/main/java/hongik/triple/commonmodule/dto/board/BoardRes.java b/common-module/src/main/java/hongik/triple/commonmodule/dto/board/BoardRes.java new file mode 100644 index 0000000..522e0df --- /dev/null +++ b/common-module/src/main/java/hongik/triple/commonmodule/dto/board/BoardRes.java @@ -0,0 +1,12 @@ +package hongik.triple.commonmodule.dto.board; + +import java.time.LocalDateTime; + +public record BoardRes( + Long boardId, + String title, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} \ No newline at end of file diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/domain/board/Board.java b/domain-module/src/main/java/hongik/triple/domainmodule/domain/board/Board.java new file mode 100644 index 0000000..7a85d23 --- /dev/null +++ b/domain-module/src/main/java/hongik/triple/domainmodule/domain/board/Board.java @@ -0,0 +1,39 @@ +package hongik.triple.domainmodule.domain.board; + +import hongik.triple.domainmodule.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +@Entity +@Getter +@Table(name = "member") +@SQLDelete(sql = "UPDATE board SET deleted_at = NOW() where banner_id = ?") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class Board extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "board_id") + private Long boardId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + // 수정 메서드 + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + @Builder + public Board(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/domain-module/src/main/java/hongik/triple/domainmodule/domain/board/repository/BoardRepository.java b/domain-module/src/main/java/hongik/triple/domainmodule/domain/board/repository/BoardRepository.java new file mode 100644 index 0000000..abf39a4 --- /dev/null +++ b/domain-module/src/main/java/hongik/triple/domainmodule/domain/board/repository/BoardRepository.java @@ -0,0 +1,14 @@ +package hongik.triple.domainmodule.domain.board.repository; + +import hongik.triple.domainmodule.domain.board.Board; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BoardRepository extends JpaRepository { + + // 페이지네이션 조회 (최신순) + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +}