From 38e7d10f3b7b1f54fdf997f3426db5ed2e8d4fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=ED=98=84=EB=B9=88?= <63556578+limhb708@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:30:55 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/controller/BoardController.java | 47 ++- .../board/dto/request/BoardRequest.java | 6 +- .../board/dto/response/BoardResponse.java | 233 ++++++++++- .../backend/board/mapper/BoardMapper.java | 22 + .../backend/board/service/BoardService.java | 17 +- .../board/service/BoardServiceImpl.java | 75 +++- .../comment/controller/CommentController.java | 175 ++++++++ .../comment/dto/request/CommentRequest.java | 73 ++++ .../comment/dto/response/CommentResponse.java | 296 ++++++++++++++ .../dodo/backend/comment/entity/Comment.java | 68 ++++ .../comment/exception/CommentErrorCode.java | 54 +++ .../comment/exception/CommentException.java | 17 + .../backend/comment/mapper/CommentMapper.java | 47 +++ .../comment/repository/CommentRepository.java | 12 + .../comment/service/CommentService.java | 57 +++ .../comment/service/CommentServiceImpl.java | 236 +++++++++++ .../board/controller/BoardControllerTest.java | 151 +++++++ .../board/service/BoardServiceTest.java | 195 ++++++++- .../controller/CommentControllerTest.java | 210 ++++++++++ .../comment/service/CommentServiceTest.java | 385 ++++++++++++++++++ 20 files changed, 2339 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/dodo/backend/comment/controller/CommentController.java create mode 100644 src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java create mode 100644 src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java create mode 100644 src/main/java/com/dodo/backend/comment/entity/Comment.java create mode 100644 src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java create mode 100644 src/main/java/com/dodo/backend/comment/exception/CommentException.java create mode 100644 src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java create mode 100644 src/main/java/com/dodo/backend/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/dodo/backend/comment/service/CommentService.java create mode 100644 src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java create mode 100644 src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java create mode 100644 src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index ed84209..f2c4de7 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import java.util.UUID; @@ -43,6 +44,36 @@ public class BoardController { private final BoardService boardService; + /** + * 공개 상태의 게시글 목록을 조회합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 게시글 목록 조회 응답 + */ + @Operation(summary = "게시글 목록 조회", description = "공개 상태의 게시글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "게시글 목록 조회를 성공했습니다.", + content = @Content(schema = @Schema(implementation = BoardListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping + public ResponseEntity getBoardList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("게시글 목록 조회 요청 수신 - User: {}, Page: {}, Size: {}", userId, page, size); + + return ResponseEntity.ok(boardService.getBoardList(page, size)); + } /** * 새 게시글을 작성합니다. * @@ -79,14 +110,13 @@ public ResponseEntity createBoard( } /** - * 수정 중인 게시글 내용을 Redis에 임시 저장합니다. + * 작성 중인 게시글 내용을 Redis에 임시 저장합니다. * - * @param boardId 임시 저장 대상 게시글 ID * @param request 임시 저장 요청 DTO * @param userDetails 인증된 사용자 정보 * @return 임시 저장 세션 키와 성공 메시지 */ - @Operation(summary = "게시글 임시 저장", description = "수정 중인 게시글 내용을 Redis에 임시 저장합니다.") + @Operation(summary = "게시글 임시 저장", description = "작성 중인 게시글의 제목, 본문, 이미지 URL 목록을 Redis에 임시 저장합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "게시글이 성공적으로 임시 저장되었습니다.", content = @Content(schema = @Schema(implementation = BoardResponse.BoardTempSaveResponse.class))), @@ -94,23 +124,18 @@ public ResponseEntity createBoard( content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "403", description = "게시글을 수정할 권한이 없습니다.", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping("/temp-save") public ResponseEntity tempSaveBoard( - @RequestParam Long boardId, @RequestBody BoardTempSaveRequest request, @AuthenticationPrincipal UserDetails userDetails ) { UUID userId = UUID.fromString(userDetails.getUsername()); - log.info("게시글 임시 저장 요청 수신 - User: {}, BoardId: {}", userId, boardId); + log.info("게시글 임시 저장 요청 수신 - User: {}", userId); - return ResponseEntity.ok(boardService.tempSaveBoard(userId, boardId, request)); + return ResponseEntity.ok(boardService.tempSaveBoard(userId, request)); } /** @@ -118,7 +143,7 @@ public ResponseEntity tempSaveBoard( * * @param sessionKey 임시 저장 데이터의 세션 키 * @param userDetails 인증된 사용자 정보 - * @return 임시 저장된 게시글 제목, 본문, 이미지 URL + * @return 임시 저장된 게시글 제목, 본문, 이미지 URL 목록 */ @Operation(summary = "임시 저장 게시글 조회", description = "sessionKey 기준으로 Redis에 저장된 임시 게시글 내용을 조회합니다.") @ApiResponses(value = { diff --git a/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java b/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java index 9bb082a..8a461a0 100644 --- a/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java +++ b/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java @@ -86,7 +86,7 @@ public static class BoardUpdateRequest { /** * 게시글 임시 저장 요청 DTO입니다. *

- * Redis에 저장할 제목, 본문, 단일 이미지 URL을 담습니다. + * Redis에 저장할 제목, 본문, 이미지 URL 목록을 담습니다. */ @Getter @Builder @@ -101,7 +101,7 @@ public static class BoardTempSaveRequest { @Schema(description = "임시 저장 게시글 내용", example = "임시 저장 내용") private String boardContent; - @Schema(description = "임시 저장 게시글 이미지 URL", example = "https://example.com/images/bori.jpg") - private String imageFileUrl; + @Schema(description = "임시 저장 게시글 이미지 URL 목록", example = "[\"https://example.com/images/bori_1.jpg\", \"https://example.com/images/bori_2.jpg\"]") + private List imageFileUrls; } } diff --git a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java index 42e4811..f6da007 100644 --- a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java +++ b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import java.time.LocalDateTime; import java.util.List; @@ -46,6 +47,234 @@ public static BoardCreateResponse toDto(Long boardId, String message) { } } + /** + * 게시글 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "게시글 목록 조회 응답") + public static class BoardListResponse { + + @Schema(description = "응답 메시지", example = "게시글 목록 조회를 성공했습니다.") + private String message; + + @Schema(description = "게시글 목록") + private List boards; + + @Schema(description = "전체 페이지 수", example = "1") + private int totalPages; + + @Schema(description = "전체 게시글 수", example = "1") + private long totalElements; + + @Schema(description = "현재 페이지 번호", example = "0") + private int currentPage; + + @Schema(description = "페이지 크기", example = "10") + private int pageSize; + + /** + * MyBatis 조회 결과를 게시글 목록 조회 응답 DTO로 변환합니다. + * + * @param queryResponses 게시글 목록 조회 결과 + * @param totalElements 전체 게시글 수 + * @param currentPage 현재 페이지 번호 + * @param pageSize 페이지 크기 + * @param message 응답 메시지 + * @return 게시글 목록 조회 응답 DTO + */ + public static BoardListResponse toDto( + List queryResponses, + long totalElements, + int currentPage, + int pageSize, + String message + ) { + List boards = queryResponses == null + ? List.of() + : queryResponses.stream() + .map(BoardListItemResponse::toDto) + .toList(); + + int totalPages = totalElements == 0 + ? 0 + : (int) Math.ceil((double) totalElements / pageSize); + + return BoardListResponse.builder() + .message(message) + .boards(boards) + .totalPages(totalPages) + .totalElements(totalElements) + .currentPage(currentPage) + .pageSize(pageSize) + .build(); + } + } + + /** + * 게시글 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "게시글 목록 아이템 응답") + public static class BoardListItemResponse { + + private static final int CONTENT_PREVIEW_LENGTH = 20; + + @Schema(description = "게시글 ID", example = "1") + private Long boardId; + + @Schema(description = "게시글 제목", example = "우리 강아지 자랑합니다") + private String boardTitle; + + @Schema(description = "게시글 본문 20자 미리보기", example = "오늘 산책하다가 찍은 사진이에요") + private String boardContentPreview; + + @Schema(description = "대표 이미지 URL", example = "https://example.com/images/bori_1.jpg") + private String thumbnailImageUrl; + + @Schema(description = "작성자 닉네임", example = "자유로운산책") + private String nickname; + + @Schema(description = "조회수", example = "51") + private Integer viewCount; + + @Schema(description = "댓글 수", example = "3") + private Long commentCount; + + @Schema(description = "좋아요 수", example = "12") + private Long likeCount; + + @Schema(description = "싫어요 수", example = "1") + private Long dislikeCount; + + @Schema(description = "게시글 생성 일시", example = "2026-01-31T13:52:32.68613") + private LocalDateTime createdAt; + + @Schema(description = "게시글 수정 일시", example = "2026-01-31T14:10:12.12345") + private LocalDateTime modifiedAt; + + /** + * 게시글 목록 조회 결과를 게시글 목록 아이템 응답 DTO로 변환합니다. + * + * @param queryResponse 게시글 목록 조회 결과 + * @return 게시글 목록 아이템 응답 DTO + */ + public static BoardListItemResponse toDto(BoardListQueryResponse queryResponse) { + return BoardListItemResponse.builder() + .boardId(queryResponse.getBoardId()) + .boardTitle(queryResponse.getBoardTitle()) + .boardContentPreview(createContentPreview(queryResponse.getBoardContent())) + .thumbnailImageUrl(queryResponse.getThumbnailImageUrl()) + .nickname(queryResponse.getNickname()) + .viewCount(queryResponse.getViewCount()) + .commentCount(defaultZero(queryResponse.getCommentCount())) + .likeCount(defaultZero(queryResponse.getLikeCount())) + .dislikeCount(defaultZero(queryResponse.getDislikeCount())) + .createdAt(queryResponse.getCreatedAt()) + .modifiedAt(queryResponse.getModifiedAt()) + .build(); + } + + /** + * 게시글 본문을 20자 미리보기 문자열로 변환합니다. + * + * @param boardContent 게시글 본문 + * @return 게시글 본문 20자 미리보기 + */ + private static String createContentPreview(String boardContent) { + if (boardContent == null) { + return ""; + } + + if (boardContent.length() <= CONTENT_PREVIEW_LENGTH) { + return boardContent; + } + + return boardContent.substring(0, CONTENT_PREVIEW_LENGTH); + } + + /** + * 숫자 값이 null이면 0을 반환합니다. + * + * @param value 변환할 숫자 값 + * @return null이 아닌 숫자 값 + */ + private static Long defaultZero(Long value) { + return value == null ? 0L : value; + } + } + + /** + * 게시글 목록 조회 MyBatis 결과 DTO입니다. + */ + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class BoardListQueryResponse { + + /** + * 게시글 ID입니다. + */ + private Long boardId; + + /** + * 게시글 제목입니다. + */ + private String boardTitle; + + /** + * 게시글 본문입니다. + */ + private String boardContent; + + /** + * 대표 이미지 URL입니다. + */ + private String thumbnailImageUrl; + + /** + * 작성자 닉네임입니다. + */ + private String nickname; + + /** + * 조회수입니다. + */ + private Integer viewCount; + + /** + * 댓글 수입니다. + */ + private Long commentCount; + + /** + * 좋아요 수입니다. + */ + private Long likeCount; + + /** + * 싫어요 수입니다. + */ + private Long dislikeCount; + + /** + * 게시글 생성 일시입니다. + */ + private LocalDateTime createdAt; + + /** + * 게시글 수정 일시입니다. + */ + private LocalDateTime modifiedAt; + } + /** * 게시글 상세 조회 응답 DTO입니다. */ @@ -195,7 +424,7 @@ public static class BoardTempSaveDetailResponse { @Schema(description = "임시 저장 게시글 내용", example = "임시 저장 내용") private String boardContent; - @Schema(description = "임시 저장 게시글 이미지 URL", example = "https://example.com/images/bori.jpg") - private String imageFileUrl; + @Schema(description = "임시 저장 게시글 이미지 URL 목록", example = "[\"https://example.com/images/bori_1.jpg\", \"https://example.com/images/bori_2.jpg\"]") + private List imageFileUrls; } } diff --git a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java index 5da2bf1..a97ac8d 100644 --- a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java +++ b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java @@ -1,15 +1,37 @@ package com.dodo.backend.board.mapper; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.List; + /** * 게시글(Board) 도메인의 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. */ @Mapper public interface BoardMapper { + /** + * 공개 상태의 게시글 목록을 페이지 단위로 조회합니다. + * + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 게시글 목록 조회 결과 + */ + List findBoardList( + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 공개 상태의 게시글 전체 개수를 조회합니다. + * + * @return 공개 게시글 전체 개수 + */ + long countPublishedBoards(); + /** * 게시글 제목과 내용을 선택적으로 수정합니다. * diff --git a/src/main/java/com/dodo/backend/board/service/BoardService.java b/src/main/java/com/dodo/backend/board/service/BoardService.java index b6b9f5b..9056f15 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -4,6 +4,7 @@ import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardSimpleResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; @@ -26,6 +27,15 @@ public interface BoardService { */ Board getBoardById(Long boardId); + /** + * 공개 상태의 게시글 목록을 조회합니다. + * + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 게시글 목록 조회 응답 DTO + */ + BoardListResponse getBoardList(int page, int size); + /** * 새 게시글을 생성하고 이미지 URL 목록을 게시글에 연결합니다. * @@ -59,15 +69,14 @@ public interface BoardService { BoardSimpleResponse updateBoard(UUID userId, Long boardId, BoardUpdateRequest request); /** - * 수정 중인 게시글 내용을 Redis에 임시 저장합니다. + * 작성 중인 게시글의 제목, 본문, 이미지 URL 목록을 Redis에 임시 저장합니다. * * @param userId 요청 사용자 ID - * @param boardId 임시 저장 대상 게시글 ID * @param request 게시글 임시 저장 요청 DTO * @return 임시 저장 세션 키가 포함된 응답 DTO - * @throws BoardException 잘못된 요청, 게시글 없음, 임시 저장 권한 없음인 경우 + * @throws BoardException 잘못된 요청인 경우 */ - BoardTempSaveResponse tempSaveBoard(UUID userId, Long boardId, BoardTempSaveRequest request); + BoardTempSaveResponse tempSaveBoard(UUID userId, BoardTempSaveRequest request); /** * Redis에 임시 저장된 게시글 내용을 조회합니다. diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index 0cadbd5..73cc6cf 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -4,6 +4,8 @@ import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardSimpleResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; @@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -54,6 +57,8 @@ public class BoardServiceImpl implements BoardService { */ private static final long TEMP_SAVE_TTL_DAYS = 7L; + private static final int MAX_BOARD_LIST_SIZE = 100; + /** * 게시글 저장 및 단건 조회를 처리하는 JPA Repository입니다. */ @@ -94,6 +99,31 @@ public Board getBoardById(Long boardId) { .orElseThrow(() -> new BoardException(BOARD_NOT_FOUND)); } + /** + * 공개 상태의 게시글 목록을 조회합니다. + * + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 게시글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public BoardListResponse getBoardList(int page, int size) { + validateBoardListRequest(page, size); + + int offset = page * size; + List queryResponses = boardMapper.findBoardList(offset, size); + long totalElements = boardMapper.countPublishedBoards(); + + return BoardListResponse.toDto( + queryResponses, + totalElements, + page, + size, + "게시글 목록 조회를 성공했습니다." + ); + } + /** * 새 게시글을 생성하고 요청에 포함된 이미지 URL 목록을 게시글에 연결합니다. * @@ -190,38 +220,29 @@ public BoardSimpleResponse updateBoard(UUID userId, Long boardId, BoardUpdateReq } /** - * 수정 중인 게시글 내용을 Redis에 임시 저장합니다. + * 작성 중인 게시글의 제목, 본문, 이미지 URL 목록을 Redis에 임시 저장합니다. *

- * 게시글 작성자만 임시 저장할 수 있으며 삭제된 게시글은 임시 저장할 수 없습니다. + * 게시글 ID를 요구하지 않으며, 아직 DB에 생성되지 않은 작성 중 초안도 저장할 수 있습니다. * 저장 데이터에는 사용자 ID를 함께 보관하여 조회 시 세션 키 소유자를 검증합니다. * * @param userId 요청 사용자 ID - * @param boardId 임시 저장 대상 게시글 ID * @param request 게시글 임시 저장 요청 DTO * @return 임시 저장 세션 키가 포함된 응답 DTO - * @throws BoardException 잘못된 요청, 게시글 없음, 임시 저장 권한 없음인 경우 + * @throws BoardException 저장할 필드가 없는 잘못된 요청인 경우 */ @Transactional @Override - public BoardTempSaveResponse tempSaveBoard(UUID userId, Long boardId, BoardTempSaveRequest request) { + public BoardTempSaveResponse tempSaveBoard(UUID userId, BoardTempSaveRequest request) { if (request == null || !hasTempSaveFields(request)) { throw new BoardException(INVALID_REQUEST); } - Board board = findBoardById(boardId); - validateBoardOwner(userId, board, TEMP_SAVE_PERMISSION_DENIED); - - if (board.getBoardStatus() == BoardStatus.DELETED) { - throw new BoardException(TEMP_SAVE_PERMISSION_DENIED); - } - String sessionKey = UUID.randomUUID().toString(); Map tempSaveData = new HashMap<>(); tempSaveData.put("userId", userId.toString()); - tempSaveData.put("boardId", boardId); tempSaveData.put("boardTitle", request.getBoardTitle()); tempSaveData.put("boardContent", request.getBoardContent()); - tempSaveData.put("imageFileUrl", request.getImageFileUrl()); + tempSaveData.put("imageFileUrls", request.getImageFileUrls()); redisTemplate.opsForValue().set( TEMP_SAVE_KEY_PREFIX + sessionKey, @@ -265,7 +286,7 @@ public BoardTempSaveDetailResponse getTempSavedBoard(UUID userId, String session .message("임시 저장된 게시글을 성공적으로 불러왔습니다.") .boardTitle(toNullableString(tempSaveData.get("boardTitle"))) .boardContent(toNullableString(tempSaveData.get("boardContent"))) - .imageFileUrl(toNullableString(tempSaveData.get("imageFileUrl"))) + .imageFileUrls(toStringList(tempSaveData.get("imageFileUrls"))) .build(); } @@ -311,6 +332,18 @@ private Board findBoardById(Long boardId) { .orElseThrow(() -> new BoardException(BOARD_NOT_FOUND)); } + /** + * 게시글 목록 조회 요청 값을 검증합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + */ + private void validateBoardListRequest(int page, int size) { + if (page < 0 || size <= 0 || size > MAX_BOARD_LIST_SIZE || page > Integer.MAX_VALUE / size) { + throw new BoardException(INVALID_REQUEST); + } + } + /** * 요청 사용자가 게시글 작성자인지 검증합니다. * @@ -359,7 +392,17 @@ private boolean hasImageUpdateFields(BoardUpdateRequest request) { private boolean hasTempSaveFields(BoardTempSaveRequest request) { return request.getBoardTitle() != null || request.getBoardContent() != null - || request.getImageFileUrl() != null; + || request.getImageFileUrls() != null; + } + + private List toStringList(Object value) { + if (!(value instanceof List values)) { + return null; + } + + return values.stream() + .map(String::valueOf) + .toList(); } /** diff --git a/src/main/java/com/dodo/backend/comment/controller/CommentController.java b/src/main/java/com/dodo/backend/comment/controller/CommentController.java new file mode 100644 index 0000000..661af80 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/controller/CommentController.java @@ -0,0 +1,175 @@ +package com.dodo.backend.comment.controller; + +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.service.CommentService; +import com.dodo.backend.common.exception.ErrorResponse; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +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.util.UUID; + +/** + * 댓글 작성, 목록 조회, 수정, 삭제 API를 제공하는 컨트롤러입니다. + *

+ * 인증된 사용자의 UUID는 {@link UserDetails#getUsername()}에서 추출하여 서비스 계층으로 전달합니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments") +@Tag(name = "Comment API", description = "댓글 관련 API") +@Slf4j +public class CommentController { + + private final CommentService commentService; + + /** + * 댓글을 작성합니다. + * + * @param request 댓글 작성 요청 DTO + * @param userDetails 인증된 사용자 정보 + * @return 댓글 작성 응답 + */ + @Operation(summary = "댓글 작성", description = "게시글에 댓글 또는 대댓글을 작성합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 작성되었습니다.", + content = @Content(schema = @Schema(implementation = CommentCreateResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + public ResponseEntity createComment( + @Valid @RequestBody CommentCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 작성 요청 수신 - User: {}, BoardId: {}", userId, request.getBoardId()); + + return ResponseEntity.ok(commentService.createComment(userId, request)); + } + + /** + * 특정 게시글의 댓글 목록을 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 댓글 목록 조회 응답 + */ + @Operation(summary = "댓글 목록 조회", description = "boardId 기준으로 댓글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글 목록을 성공적으로 조회했습니다.", + content = @Content(schema = @Schema(implementation = CommentListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/{boardId}") + public ResponseEntity getComments( + @PathVariable Long boardId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 목록 조회 요청 수신 - User: {}, BoardId: {}, Page: {}, Size: {}", userId, boardId, page, size); + + return ResponseEntity.ok(commentService.getComments(boardId, page, size)); + } + + /** + * 특정 댓글을 수정합니다. + * + * @param commentId 수정할 댓글 ID + * @param request 댓글 수정 요청 DTO + * @param userDetails 인증된 사용자 정보 + * @return 댓글 수정 응답 + */ + @Operation(summary = "댓글 수정", description = "commentId 기준으로 댓글 내용을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 수정되었습니다.", + content = @Content(schema = @Schema(implementation = CommentSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "댓글을 수정할 권한이 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 ID의 댓글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PatchMapping("/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 수정 요청 수신 - User: {}, CommentId: {}", userId, commentId); + + return ResponseEntity.ok(commentService.updateComment(userId, commentId, request)); + } + + /** + * 특정 댓글을 삭제 상태로 변경합니다. + * + * @param commentId 삭제할 댓글 ID + * @param userDetails 인증된 사용자 정보 + * @return 댓글 삭제 응답 + */ + @Operation(summary = "댓글 삭제", description = "commentId 기준으로 댓글을 삭제 상태로 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = CommentSimpleResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "특정 댓글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 삭제 요청 수신 - User: {}, CommentId: {}", userId, commentId); + + return ResponseEntity.ok(commentService.deleteComment(userId, commentId)); + } +} diff --git a/src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java b/src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java new file mode 100644 index 0000000..1596077 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java @@ -0,0 +1,73 @@ +package com.dodo.backend.comment.dto.request; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 댓글 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "댓글 요청 DTO 그룹") +public class CommentRequest { + + /** + * 댓글 작성 요청 DTO입니다. + */ + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "댓글 작성 요청") + public static class CommentCreateRequest { + + @NotNull(message = "게시글 ID는 필수입니다.") + @Schema(description = "댓글을 작성할 게시글 ID", example = "1") + private Long boardId; + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다!") + private String commentContent; + + @Schema(description = "부모 댓글 ID", example = "null") + private Long parentCommentId; + + /** + * 댓글 작성 요청 정보를 {@link Comment} 엔티티로 변환합니다. + * + * @param board 댓글이 작성될 게시글 + * @param user 댓글 작성자 + * @param parentComment 부모 댓글 + * @return 댓글 엔티티 + */ + public Comment toEntity(Board board, User user, Comment parentComment) { + return Comment.builder() + .board(board) + .user(user) + .parentComment(parentComment) + .commentContent(this.commentContent) + .build(); + } + } + + /** + * 댓글 수정 요청 DTO입니다. + */ + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "댓글 수정 요청") + public static class CommentUpdateRequest { + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Schema(description = "수정할 댓글 내용", example = "수정입니다.") + private String commentContent; + } +} diff --git a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..c68110c --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java @@ -0,0 +1,296 @@ +package com.dodo.backend.comment.dto.response; + +import com.dodo.backend.comment.entity.Comment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 댓글 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "댓글 응답 DTO 그룹") +public class CommentResponse { + + /** + * 댓글 작성 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 작성 응답") + public static class CommentCreateResponse { + + @Schema(description = "응답 메시지", example = "댓글이 성공적으로 작성되었습니다.") + private String message; + + @Schema(description = "댓글 ID", example = "123") + private Long commentId; + + @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다!") + private String commentContent; + + @Schema(description = "작성자 ID", example = "uuid-user-1") + private String userId; + + @Schema(description = "작성자 닉네임", example = "멍멍이집사") + private String nickname; + + /** + * 댓글 엔티티를 댓글 작성 응답 DTO로 변환합니다. + * + * @param comment 댓글 엔티티 + * @param message 응답 메시지 + * @return 댓글 작성 응답 DTO + */ + public static CommentCreateResponse toDto(Comment comment, String message) { + return CommentCreateResponse.builder() + .message(message) + .commentId(comment.getCommentId()) + .commentContent(comment.getCommentContent()) + .userId(comment.getUser().getUsersId().toString()) + .nickname(comment.getUser().getNickname()) + .build(); + } + } + + /** + * 댓글 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 목록 조회 응답") + public static class CommentListResponse { + + @Schema(description = "응답 메시지", example = "댓글 목록을 성공적으로 조회했습니다.") + private String message; + + @Schema(description = "페이지 정보") + private PageInfoResponse pageInfo; + + @Schema(description = "댓글 목록") + private List data; + + /** + * MyBatis 조회 결과를 댓글 목록 조회 응답 DTO로 변환합니다. + * + * @param queryResponses 댓글 목록 조회 결과 + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 댓글 수 + * @param message 응답 메시지 + * @return 댓글 목록 조회 응답 DTO + */ + public static CommentListResponse toDto( + List queryResponses, + int page, + int size, + long totalElements, + String message + ) { + List data = queryResponses == null + ? List.of() + : queryResponses.stream() + .map(CommentItemResponse::toDto) + .toList(); + + return CommentListResponse.builder() + .message(message) + .pageInfo(PageInfoResponse.toDto(page, size, totalElements)) + .data(data) + .build(); + } + } + + /** + * 페이지 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "페이지 정보 응답") + public static class PageInfoResponse { + + @Schema(description = "현재 페이지 번호", example = "0") + private int page; + + @Schema(description = "페이지 크기", example = "10") + private int size; + + @Schema(description = "전체 요소 수", example = "48") + private long totalElements; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + /** + * 페이지 정보 응답 DTO를 생성합니다. + * + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 요소 수 + * @return 페이지 정보 응답 DTO + */ + public static PageInfoResponse toDto(int page, int size, long totalElements) { + int totalPages = totalElements == 0 + ? 0 + : (int) Math.ceil((double) totalElements / size); + + return PageInfoResponse.builder() + .page(page) + .size(size) + .totalElements(totalElements) + .totalPages(totalPages) + .build(); + } + } + + /** + * 댓글 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 목록 아이템 응답") + public static class CommentItemResponse { + + @Schema(description = "댓글 ID", example = "102") + private Long commentId; + + @Schema(description = "부모 댓글 ID", example = "null") + private Long parentCommentId; + + @Schema(description = "댓글 내용", example = "두 번째 댓글입니다.") + private String commentContent; + + @Schema(description = "작성자 정보") + private CommentAuthorResponse author; + + @Schema(description = "댓글 작성 일시", example = "2025-10-14T14:30:00") + private LocalDateTime createdAt; + + /** + * 댓글 목록 조회 결과를 댓글 목록 아이템 응답 DTO로 변환합니다. + * + * @param queryResponse 댓글 목록 조회 결과 + * @return 댓글 목록 아이템 응답 DTO + */ + public static CommentItemResponse toDto(CommentListQueryResponse queryResponse) { + return CommentItemResponse.builder() + .commentId(queryResponse.getCommentId()) + .parentCommentId(queryResponse.getParentCommentId()) + .commentContent(queryResponse.getCommentContent()) + .author(CommentAuthorResponse.toDto(queryResponse.getUserId(), queryResponse.getNickname())) + .createdAt(queryResponse.getCreatedAt()) + .build(); + } + } + + /** + * 댓글 작성자 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 작성자 응답") + public static class CommentAuthorResponse { + + @Schema(description = "작성자 ID", example = "uuid-user-1") + private String userId; + + @Schema(description = "작성자 닉네임", example = "행복한강아지") + private String nickname; + + /** + * 댓글 작성자 응답 DTO를 생성합니다. + * + * @param userId 작성자 ID + * @param nickname 작성자 닉네임 + * @return 댓글 작성자 응답 DTO + */ + public static CommentAuthorResponse toDto(String userId, String nickname) { + return CommentAuthorResponse.builder() + .userId(userId) + .nickname(nickname) + .build(); + } + } + + /** + * 댓글 목록 조회 MyBatis 결과 DTO입니다. + */ + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class CommentListQueryResponse { + + /** + * 댓글 ID입니다. + */ + private Long commentId; + + /** + * 부모 댓글 ID입니다. + */ + private Long parentCommentId; + + /** + * 댓글 내용입니다. + */ + private String commentContent; + + /** + * 작성자 ID입니다. + */ + private String userId; + + /** + * 작성자 닉네임입니다. + */ + private String nickname; + + /** + * 댓글 작성 일시입니다. + */ + private LocalDateTime createdAt; + } + + /** + * 댓글 수정, 삭제처럼 메시지만 반환하는 단순 처리 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 단순 처리 응답") + public static class CommentSimpleResponse { + + @Schema(description = "응답 메시지", example = "댓글이 성공적으로 수정되었습니다.") + private String message; + + /** + * 댓글 단순 처리 응답 DTO를 생성합니다. + * + * @param message 응답 메시지 + * @return 댓글 단순 처리 응답 DTO + */ + public static CommentSimpleResponse toDto(String message) { + return CommentSimpleResponse.builder() + .message(message) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/comment/entity/Comment.java b/src/main/java/com/dodo/backend/comment/entity/Comment.java new file mode 100644 index 0000000..2b7f063 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/entity/Comment.java @@ -0,0 +1,68 @@ +package com.dodo.backend.comment.entity; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 댓글(Comment) 정보를 관리하는 엔티티입니다. + *

+ * 데이터베이스 {@code comment} 테이블과 매핑되며, 게시글, 작성자, 부모 댓글, + * 댓글 내용, 생성일 및 수정일 정보를 포함합니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table(name = "comment") +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long commentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id", nullable = false) + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parentComment; + + @Column(name = "comment_content", columnDefinition = "TEXT", nullable = false) + private String commentContent; + + @CreatedDate + @Column(name = "comment_created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "comment_modified_at") + private LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java b/src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..65323ec --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java @@ -0,0 +1,54 @@ +package com.dodo.backend.comment.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 댓글(Comment) 도메인에서 발생하는 예외 상황을 관리하는 에러 코드 정의 클래스입니다. + */ +@AllArgsConstructor +@Getter +public enum CommentErrorCode implements BaseErrorCode { + + /** + * 클라이언트 요청이 잘못되었을 때 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 인증되지 않은 사용자가 댓글 API를 요청했을 때 사용합니다. + */ + LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "로그인이 필요한 기능입니다."), + + /** + * 요청한 댓글을 찾을 수 없을 때 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 댓글을 찾을 수 없습니다."), + + /** + * 댓글 수정 권한이 없을 때 사용합니다. + */ + UPDATE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "댓글을 수정할 권한이 없습니다."), + + /** + * 댓글 삭제 권한이 없을 때 사용합니다. + */ + DELETE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "댓글을 삭제할 권한이 없습니다."), + + /** + * 서버 내부에서 예상치 못한 오류가 발생했을 때 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * 에러 상황에 해당하는 HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 클라이언트에게 전달할 상세 에러 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/comment/exception/CommentException.java b/src/main/java/com/dodo/backend/comment/exception/CommentException.java new file mode 100644 index 0000000..08ddf96 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/exception/CommentException.java @@ -0,0 +1,17 @@ +package com.dodo.backend.comment.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 댓글(Comment) 도메인 비즈니스 로직 수행 중 발생하는 전용 예외 클래스입니다. + */ +@AllArgsConstructor +@Getter +public class CommentException extends RuntimeException { + + /** + * 발생한 예외의 구체적인 종류를 담고 있는 Enum입니다. + */ + private final CommentErrorCode errorCode; +} diff --git a/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..b890dbe --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java @@ -0,0 +1,47 @@ +package com.dodo.backend.comment.mapper; + +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 댓글(Comment) 도메인의 목록 조회 및 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. + */ +@Mapper +public interface CommentMapper { + + /** + * 특정 게시글의 댓글 목록을 페이지 단위로 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 댓글 목록 조회 결과 + */ + List findCommentsByBoardId( + @Param("boardId") Long boardId, + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 특정 게시글의 댓글 전체 개수를 조회합니다. + * + * @param boardId 댓글 수를 조회할 게시글 ID + * @return 댓글 전체 개수 + */ + long countCommentsByBoardId(@Param("boardId") Long boardId); + + /** + * 댓글 내용을 수정합니다. + * + * @param commentId 수정할 댓글 ID + * @param commentContent 수정할 댓글 내용 + */ + void updateComment( + @Param("commentId") Long commentId, + @Param("commentContent") String commentContent + ); +} diff --git a/src/main/java/com/dodo/backend/comment/repository/CommentRepository.java b/src/main/java/com/dodo/backend/comment/repository/CommentRepository.java new file mode 100644 index 0000000..9860f1f --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/repository/CommentRepository.java @@ -0,0 +1,12 @@ +package com.dodo.backend.comment.repository; + +import com.dodo.backend.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * {@link Comment} 엔티티의 데이터베이스 접근을 담당하는 리포지토리 인터페이스입니다. + */ +@Repository +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/com/dodo/backend/comment/service/CommentService.java b/src/main/java/com/dodo/backend/comment/service/CommentService.java new file mode 100644 index 0000000..5f01e58 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/service/CommentService.java @@ -0,0 +1,57 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.exception.CommentException; + +import java.util.UUID; + +/** + * 댓글(Comment) 도메인의 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface CommentService { + + /** + * 댓글을 작성합니다. + * + * @param userId 요청 사용자 ID + * @param request 댓글 작성 요청 DTO + * @return 댓글 작성 응답 DTO + * @throws CommentException 잘못된 요청 또는 부모 댓글이 없는 경우 + */ + CommentCreateResponse createComment(UUID userId, CommentCreateRequest request); + + /** + * 특정 게시글의 댓글 목록을 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 댓글 목록 조회 응답 DTO + */ + CommentListResponse getComments(Long boardId, int page, int size); + + /** + * 특정 댓글을 수정합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 수정할 댓글 ID + * @param request 댓글 수정 요청 DTO + * @return 댓글 수정 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 수정 권한 없음인 경우 + */ + CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentUpdateRequest request); + + /** + * 특정 댓글을 삭제 상태로 변경합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 삭제할 댓글 ID + * @return 댓글 삭제 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 삭제 권한 없음인 경우 + */ + CommentSimpleResponse deleteComment(UUID userId, Long commentId); +} diff --git a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java new file mode 100644 index 0000000..e4bb7b6 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java @@ -0,0 +1,236 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.exception.CommentErrorCode; +import com.dodo.backend.comment.exception.CommentException; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static com.dodo.backend.comment.exception.CommentErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.comment.exception.CommentErrorCode.DELETE_PERMISSION_DENIED; +import static com.dodo.backend.comment.exception.CommentErrorCode.INVALID_REQUEST; +import static com.dodo.backend.comment.exception.CommentErrorCode.UPDATE_PERMISSION_DENIED; + +/** + * {@link CommentService} 구현체입니다. + *

+ * 댓글 작성, 목록 조회, 수정, 삭제 비즈니스 로직을 처리합니다. + */ +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private static final int MAX_COMMENT_LIST_SIZE = 100; + + private final CommentRepository commentRepository; + private final CommentMapper commentMapper; + private final BoardService boardService; + private final UserService userService; + + /** + * 댓글을 작성합니다. + *

+ * 댓글 작성 시 게시글은 BoardService를 통해 조회하고, 작성자는 UserService를 통해 조회합니다. + * 부모 댓글 ID가 전달되면 같은 게시글의 댓글인지 검증한 뒤 대댓글로 저장합니다. + * + * @param userId 요청 사용자 ID + * @param request 댓글 작성 요청 DTO + * @return 댓글 작성 응답 DTO + * @throws CommentException 잘못된 요청 또는 부모 댓글이 없는 경우 + */ + @Override + @Transactional + public CommentCreateResponse createComment(UUID userId, CommentCreateRequest request) { + if (userId == null || request == null || isBlank(request.getCommentContent())) { + throw new CommentException(INVALID_REQUEST); + } + + Board board = boardService.getBoardById(request.getBoardId()); + validateCommentableBoard(board); + + User user = userService.getUserById(userId); + Comment parentComment = findParentComment(request.getParentCommentId(), board.getBoardId()); + + Comment comment = request.toEntity(board, user, parentComment); + Comment savedComment = commentRepository.save(comment); + + return CommentCreateResponse.toDto(savedComment, "댓글이 성공적으로 작성되었습니다."); + } + + /** + * 특정 게시글의 댓글 목록을 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 댓글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public CommentListResponse getComments(Long boardId, int page, int size) { + validateCommentListRequest(boardId, page, size); + + Board board = boardService.getBoardById(boardId); + validateCommentableBoard(board); + + int offset = page * size; + List queryResponses = commentMapper.findCommentsByBoardId(boardId, offset, size); + long totalElements = commentMapper.countCommentsByBoardId(boardId); + + return CommentListResponse.toDto( + queryResponses, + page, + size, + totalElements, + "댓글 목록을 성공적으로 조회했습니다." + ); + } + + /** + * 특정 댓글을 수정합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 수정할 댓글 ID + * @param request 댓글 수정 요청 DTO + * @return 댓글 수정 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 수정 권한 없음인 경우 + */ + @Override + @Transactional + public CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentUpdateRequest request) { + if (userId == null || request == null || isBlank(request.getCommentContent())) { + throw new CommentException(INVALID_REQUEST); + } + + Comment comment = findCommentById(commentId); + validateCommentOwner(userId, comment, UPDATE_PERMISSION_DENIED); + + commentMapper.updateComment(commentId, request.getCommentContent()); + + return CommentSimpleResponse.toDto("댓글이 성공적으로 수정되었습니다."); + } + + /** + * 특정 댓글을 삭제합니다. + *

+ * 댓글 삭제는 기본 삭제 작업이므로 JPA Repository를 사용합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 삭제할 댓글 ID + * @return 댓글 삭제 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 삭제 권한 없음인 경우 + */ + @Override + @Transactional + public CommentSimpleResponse deleteComment(UUID userId, Long commentId) { + if (userId == null) { + throw new CommentException(INVALID_REQUEST); + } + + Comment comment = findCommentById(commentId); + validateCommentOwner(userId, comment, DELETE_PERMISSION_DENIED); + + commentRepository.delete(comment); + + return CommentSimpleResponse.toDto("댓글이 성공적으로 삭제되었습니다."); + } + + /** + * 댓글 ID로 댓글을 조회하고 ID 유효성을 검증합니다. + * + * @param commentId 조회할 댓글 ID + * @return 댓글 엔티티 + */ + private Comment findCommentById(Long commentId) { + if (commentId == null || commentId <= 0) { + throw new CommentException(INVALID_REQUEST); + } + + return commentRepository.findById(commentId) + .orElseThrow(() -> new CommentException(COMMENT_NOT_FOUND)); + } + + /** + * 부모 댓글을 조회하고 같은 게시글의 댓글인지 검증합니다. + * + * @param parentCommentId 부모 댓글 ID + * @param boardId 게시글 ID + * @return 부모 댓글 또는 null + */ + private Comment findParentComment(Long parentCommentId, Long boardId) { + if (parentCommentId == null) { + return null; + } + + Comment parentComment = findCommentById(parentCommentId); + if (parentComment.getBoard() == null || !boardId.equals(parentComment.getBoard().getBoardId())) { + throw new CommentException(INVALID_REQUEST); + } + + return parentComment; + } + + /** + * 댓글 작성 또는 조회 가능한 게시글인지 검증합니다. + * + * @param board 검증할 게시글 + */ + private void validateCommentableBoard(Board board) { + if (board == null || board.getBoardStatus() != BoardStatus.PUBLISHED) { + throw new CommentException(INVALID_REQUEST); + } + } + + /** + * 댓글 목록 조회 요청 값을 검증합니다. + * + * @param boardId 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + */ + private void validateCommentListRequest(Long boardId, int page, int size) { + if (boardId == null || boardId <= 0 || page < 0 || size <= 0 || size > MAX_COMMENT_LIST_SIZE || page > Integer.MAX_VALUE / size) { + throw new CommentException(INVALID_REQUEST); + } + } + + /** + * 댓글 작성자인지 검증합니다. + * + * @param userId 요청 사용자 ID + * @param comment 검증할 댓글 + * @param errorCode 권한이 없을 때 발생시킬 에러 코드 + */ + private void validateCommentOwner(UUID userId, Comment comment, CommentErrorCode errorCode) { + if (comment.getUser() == null || !userId.equals(comment.getUser().getUsersId())) { + throw new CommentException(errorCode); + } + } + + /** + * 문자열이 null이거나 공백인지 확인합니다. + * + * @param value 확인할 문자열 + * @return null 또는 공백이면 true + */ + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java index b161282..5e4de88 100644 --- a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java +++ b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java @@ -13,6 +13,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListItemResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; import java.time.LocalDateTime; import java.util.List; @@ -33,6 +38,60 @@ class BoardControllerTest { @Mock private BoardService boardService; + /** + * 게시글 목록 조회 요청 시 200 상태코드와 게시글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 목록 조회 성공: 200 상태코드와 게시글 목록을 반환한다.") + void getBoardList_Success() { + log.info("테스트 시작: 게시글 목록 조회 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + + BoardListItemResponse itemResponse = BoardListItemResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContentPreview("오늘 산책하다가 찍은 사진이에요") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(0L) + .dislikeCount(0L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + BoardListResponse serviceResponse = BoardListResponse.builder() + .message("게시글 목록 조회를 성공했습니다.") + .boards(List.of(itemResponse)) + .totalPages(1) + .totalElements(1L) + .currentPage(0) + .pageSize(10) + .build(); + + given(boardService.getBoardList(0, 10)).willReturn(serviceResponse); + + // when + ResponseEntity response = boardController.getBoardList(0, 10, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글 목록 조회를 성공했습니다.", response.getBody().getMessage()); + assertEquals(1, response.getBody().getBoards().size()); + assertEquals(1L, response.getBody().getBoards().get(0).getBoardId()); + + verify(boardService).getBoardList(0, 10); + + log.info("테스트 종료: 게시글 목록 조회 성공 시나리오"); + } + /** * 게시글 작성 요청 시 200 상태코드와 생성된 게시글 ID를 반환하는지 검증합니다. */ @@ -78,6 +137,85 @@ void createBoard_Success() { log.info("테스트 종료: 게시글 작성 성공 시나리오"); } + /** + * 게시글 임시 저장 요청 시 200 상태코드와 세션 키를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 임시 저장 성공: 200 상태코드와 세션 키를 반환한다.") + void tempSaveBoard_Success() { + log.info("테스트 시작: 게시글 임시 저장 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + + BoardTempSaveRequest request = BoardTempSaveRequest.builder() + .boardTitle("임시 저장 제목") + .boardContent("임시 저장 내용") + .imageFileUrls(List.of("https://example.com/images/bori.jpg")) + .build(); + + BoardTempSaveResponse serviceResponse = BoardTempSaveResponse.toDto( + "session-key", + "게시글이 성공적으로 임시 저장되었습니다." + ); + + given(boardService.tempSaveBoard(userId, request)).willReturn(serviceResponse); + + // when + ResponseEntity response = boardController.tempSaveBoard(request, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글이 성공적으로 임시 저장되었습니다.", response.getBody().getMessage()); + assertEquals("session-key", response.getBody().getSessionKey()); + + verify(boardService).tempSaveBoard(userId, request); + + log.info("테스트 종료: 게시글 임시 저장 성공 시나리오"); + } + + /** + * 임시 저장 게시글 조회 요청 시 200 상태코드와 저장된 데이터를 반환하는지 검증합니다. + */ + @Test + @DisplayName("임시 저장 게시글 조회 성공: 200 상태코드와 저장된 데이터를 반환한다.") + void getTempSavedBoard_Success() { + log.info("테스트 시작: 임시 저장 게시글 조회 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + String sessionKey = "session-key"; + UserDetails userDetails = createUserDetails(userId); + + BoardTempSaveDetailResponse serviceResponse = BoardTempSaveDetailResponse.builder() + .message("임시 저장된 게시글을 성공적으로 불러왔습니다.") + .boardTitle("임시 저장 제목") + .boardContent("임시 저장 내용") + .imageFileUrls(List.of("https://example.com/images/bori.jpg")) + .build(); + + given(boardService.getTempSavedBoard(userId, sessionKey)).willReturn(serviceResponse); + + // when + ResponseEntity response = boardController.getTempSavedBoard(sessionKey, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("임시 저장된 게시글을 성공적으로 불러왔습니다.", response.getBody().getMessage()); + assertEquals("임시 저장 제목", response.getBody().getBoardTitle()); + + verify(boardService).getTempSavedBoard(userId, sessionKey); + + log.info("테스트 종료: 임시 저장 게시글 조회 성공 시나리오"); + } + /** * 게시글 상세 조회 요청 시 200 상태코드와 게시글 상세 정보를 반환하는지 검증합니다. */ @@ -212,4 +350,17 @@ void deleteBoard_Success() { verify(boardService).deleteBoard(userId, boardId); log.info("테스트 종료: 게시글 삭제 성공 시나리오"); } + + /** + * 테스트용 인증 사용자 정보를 생성합니다. + * + * @param userId 사용자 ID + * @return 인증 사용자 정보 + */ + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } } diff --git a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java index 7789d06..6a9cf51 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -1,9 +1,14 @@ package com.dodo.backend.board.service; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardSimpleResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; @@ -22,6 +27,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import java.time.LocalDateTime; import java.util.List; @@ -36,6 +43,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; /** * {@link BoardService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. @@ -59,6 +71,77 @@ class BoardServiceTest { @Mock private BoardMapper boardMapper; + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + /** + * 게시글 목록 조회 시 게시글 목록과 페이지 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 목록 조회 성공: 게시글 목록과 페이지 정보를 반환한다.") + void getBoardList_Success() { + log.info("테스트 시작: 게시글 목록 조회 성공"); + + // given + BoardListQueryResponse queryResponse = BoardListQueryResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContent("오늘 산책하다가 찍은 사진이에요. 너무 귀엽죠?") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(0L) + .dislikeCount(0L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + given(boardMapper.findBoardList(0, 10)).willReturn(List.of(queryResponse)); + given(boardMapper.countPublishedBoards()).willReturn(1L); + + // when + BoardListResponse response = boardService.getBoardList(0, 10); + + // then + assertNotNull(response); + assertEquals("게시글 목록 조회를 성공했습니다.", response.getMessage()); + assertEquals(1, response.getBoards().size()); + assertEquals(1, response.getTotalPages()); + assertEquals(1L, response.getTotalElements()); + assertEquals(0, response.getCurrentPage()); + assertEquals(10, response.getPageSize()); + assertEquals("우리 강아지 자랑합니다", response.getBoards().get(0).getBoardTitle()); + assertEquals("오늘 산책하다가 찍은 사진이에요. 너", response.getBoards().get(0).getBoardContentPreview()); + + verify(boardMapper).findBoardList(0, 10); + verify(boardMapper).countPublishedBoards(); + + log.info("테스트 종료: 게시글 목록 조회 성공"); + } + + /** + * 게시글 목록 조회 시 잘못된 페이지 크기이면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 목록 조회 실패: 페이지 크기가 100보다 크면 예외가 발생한다.") + void getBoardList_Fail_InvalidSize() { + log.info("테스트 시작: 게시글 목록 조회 실패 - 잘못된 페이지 크기"); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getBoardList(0, 101)); + + // then + assertEquals(BoardErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(boardMapper, never()).findBoardList(0, 101); + + log.info("테스트 종료: 게시글 목록 조회 실패 - 잘못된 페이지 크기"); + } + /** * 게시글 작성 요청 시 작성자를 조회하고 게시글을 저장한 뒤 게시글 ID를 반환하는지 검증합니다. */ @@ -237,7 +320,7 @@ void getBoardDetail_Fail_InvalidBoardId() { */ @Test @DisplayName("게시글 상세 조회 실패: 게시글을 찾을 수 없으면 예외가 발생한다.") - void getBoardDetail_Fail_BoardNotFound() { + void getBoardDetail_Fail_BoardNotFound_Duplicate() { log.info("테스트 시작: 게시글 상세 조회 실패 - 게시글 없음"); // given @@ -286,6 +369,116 @@ void getBoardDetail_Fail_ViewPermissionDenied() { verify(boardRepository).findById(boardId); log.info("테스트 종료: 게시글 상세 조회 실패 - 조회 권한 없음 검증 완료"); } + /** + * 조회할 게시글이 존재하지 않으면 게시글 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 상세 조회 실패: 게시글을 찾을 수 없으면 예외가 발생한다.") + void getBoardDetail_Fail_BoardNotFound() { + log.info("테스트 시작: 게시글 상세 조회 실패 - 게시글 없음"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 999L; + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getBoardDetail(userId, boardId)); + + // then + assertEquals(BoardErrorCode.BOARD_NOT_FOUND, exception.getErrorCode()); + verify(boardRepository).findById(boardId); + log.info("테스트 종료: 게시글 상세 조회 실패 - 게시글 없음 검증 완료"); + } + + + /** + * 작성자 본인이 게시글 제목, 내용, 이미지를 수정하면 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 수정 성공: 작성자 본인이 게시글을 수정한다.") + void updateBoard_Success_Duplicate() { + log.info("테스트 시작: 게시글 수정 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + + Board board = Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("기존 제목") + .boardContent("기존 내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + BoardUpdateRequest request = BoardUpdateRequest.builder() + .boardTitle("우와 우리 애가!") + .boardContent("산책을 했어요!!") + .imageFileUrls(List.of( + "https://example.com/images/bori_1.jpg", + "https://example.com/images/bori_2.jpg" + )) + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardSimpleResponse response = boardService.updateBoard(userId, boardId, request); + + // then + assertEquals("게시글이 성공적으로 수정되었습니다.", response.getMessage()); + verify(boardRepository).findById(boardId); + verify(boardMapper).updateBoard(boardId, request); + verify(imageFileService).replaceBoardImages(board, request.getImageFileUrls()); + + log.info("테스트 종료: 게시글 수정 성공 검증 완료"); + } + + /** + * 작성자가 아닌 사용자가 게시글을 수정하면 수정 권한 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 수정 실패: 작성자가 아니면 예외가 발생한다.") + void updateBoard_Fail_PermissionDenied_Duplicate() { + log.info("테스트 시작: 게시글 수정 실패 - 권한 없음"); + + // given + UUID ownerId = UUID.randomUUID(); + UUID requestUserId = UUID.randomUUID(); + Long boardId = 1L; + User owner = mock(User.class); + given(owner.getUsersId()).willReturn(ownerId); + + Board board = Board.builder() + .boardId(boardId) + .user(owner) + .boardTitle("기존 제목") + .boardContent("기존 내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + BoardUpdateRequest request = BoardUpdateRequest.builder() + .boardTitle("수정 제목") + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.updateBoard(requestUserId, boardId, request)); + + // then + assertEquals(BoardErrorCode.UPDATE_PERMISSION_DENIED, exception.getErrorCode()); + verify(boardRepository).findById(boardId); + + log.info("테스트 종료: 게시글 수정 실패 - 권한 없음 검증 완료"); + } /** * 작성자 본인이 게시글 제목, 내용, 이미지를 수정하면 성공 메시지를 반환하는지 검증합니다. diff --git a/src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java b/src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..8c0f9b4 --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java @@ -0,0 +1,210 @@ +package com.dodo.backend.comment.controller; + +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentAuthorResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentItemResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.PageInfoResponse; +import com.dodo.backend.comment.service.CommentService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * {@link CommentController}의 HTTP 요청 처리 로직을 검증하는 테스트 클래스입니다. + */ +@Slf4j +@ExtendWith(MockitoExtension.class) +class CommentControllerTest { + + @Mock + private CommentService commentService; + + /** + * 댓글 작성 요청 시 200 상태코드와 작성된 댓글 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 작성 성공: 200 상태코드와 작성된 댓글 정보를 반환한다.") + void createComment_Success() { + log.info("테스트 시작: 댓글 작성 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(1L) + .commentContent("좋은 정보 감사합니다!") + .parentCommentId(null) + .build(); + + CommentCreateResponse serviceResponse = CommentCreateResponse.builder() + .message("댓글이 성공적으로 작성되었습니다.") + .commentId(123L) + .commentContent("좋은 정보 감사합니다!") + .userId(userId.toString()) + .nickname("멍멍이집사") + .build(); + + given(commentService.createComment(userId, request)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.createComment(request, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글이 성공적으로 작성되었습니다.", response.getBody().getMessage()); + assertEquals(123L, response.getBody().getCommentId()); + assertEquals("멍멍이집사", response.getBody().getNickname()); + + verify(commentService).createComment(userId, request); + + log.info("테스트 종료: 댓글 작성 성공 시나리오"); + } + + /** + * 댓글 목록 조회 요청 시 200 상태코드와 댓글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 목록 조회 성공: 200 상태코드와 댓글 목록을 반환한다.") + void getComments_Success() { + log.info("테스트 시작: 댓글 목록 조회 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + UserDetails userDetails = createUserDetails(userId); + + CommentItemResponse itemResponse = CommentItemResponse.builder() + .commentId(102L) + .parentCommentId(null) + .commentContent("두 번째 댓글입니다.") + .author(CommentAuthorResponse.toDto("uuid-user-2", "산책왕뽀삐")) + .createdAt(LocalDateTime.of(2025, 10, 14, 14, 30)) + .build(); + + CommentListResponse serviceResponse = CommentListResponse.builder() + .message("댓글 목록을 성공적으로 조회했습니다.") + .pageInfo(PageInfoResponse.toDto(0, 10, 1L)) + .data(List.of(itemResponse)) + .build(); + + given(commentService.getComments(boardId, 0, 10)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.getComments(boardId, 0, 10, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글 목록을 성공적으로 조회했습니다.", response.getBody().getMessage()); + assertEquals(1, response.getBody().getData().size()); + assertEquals(102L, response.getBody().getData().get(0).getCommentId()); + + verify(commentService).getComments(boardId, 0, 10); + + log.info("테스트 종료: 댓글 목록 조회 성공 시나리오"); + } + + /** + * 댓글 수정 요청 시 200 상태코드와 수정 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 수정 성공: 200 상태코드와 수정 성공 메시지를 반환한다.") + void updateComment_Success() { + log.info("테스트 시작: 댓글 수정 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + UserDetails userDetails = createUserDetails(userId); + + CommentUpdateRequest request = CommentUpdateRequest.builder() + .commentContent("수정입니다.") + .build(); + + CommentSimpleResponse serviceResponse = CommentSimpleResponse.toDto("댓글이 성공적으로 수정되었습니다."); + given(commentService.updateComment(userId, commentId, request)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.updateComment(commentId, request, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글이 성공적으로 수정되었습니다.", response.getBody().getMessage()); + + verify(commentService).updateComment(userId, commentId, request); + + log.info("테스트 종료: 댓글 수정 성공 시나리오"); + } + + /** + * 댓글 삭제 요청 시 200 상태코드와 삭제 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 삭제 성공: 200 상태코드와 삭제 성공 메시지를 반환한다.") + void deleteComment_Success() { + log.info("테스트 시작: 댓글 삭제 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + UserDetails userDetails = createUserDetails(userId); + + CommentSimpleResponse serviceResponse = CommentSimpleResponse.toDto("댓글이 성공적으로 삭제되었습니다."); + given(commentService.deleteComment(userId, commentId)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.deleteComment(commentId, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글이 성공적으로 삭제되었습니다.", response.getBody().getMessage()); + + verify(commentService).deleteComment(userId, commentId); + + log.info("테스트 종료: 댓글 삭제 성공 시나리오"); + } + + /** + * 테스트용 인증 사용자 정보를 생성합니다. + * + * @param userId 사용자 ID + * @return 인증 사용자 정보 + */ + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java b/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..12a7474 --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java @@ -0,0 +1,385 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.exception.CommentErrorCode; +import com.dodo.backend.comment.exception.CommentException; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * {@link CommentService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. + */ +@Slf4j +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentMapper commentMapper; + + @Mock + private BoardService boardService; + + @Mock + private UserService userService; + + /** + * 댓글 작성 요청 시 게시글과 사용자를 조회하고 댓글을 저장하는지 검증합니다. + */ + @Test + @DisplayName("댓글 작성 성공: 댓글이 정상적으로 저장되고 작성 응답을 반환한다.") + void createComment_Success() { + log.info("테스트 시작: 댓글 작성 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + given(user.getNickname()).willReturn("멍멍이집사"); + + Board board = Board.builder() + .boardId(boardId) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(boardId) + .commentContent("좋은 정보 감사합니다!") + .parentCommentId(null) + .build(); + + Comment savedComment = Comment.builder() + .commentId(123L) + .board(board) + .user(user) + .commentContent(request.getCommentContent()) + .build(); + + given(boardService.getBoardById(boardId)).willReturn(board); + given(userService.getUserById(userId)).willReturn(user); + given(commentRepository.save(any(Comment.class))).willReturn(savedComment); + + // when + CommentCreateResponse response = commentService.createComment(userId, request); + + // then + assertNotNull(response); + assertEquals("댓글이 성공적으로 작성되었습니다.", response.getMessage()); + assertEquals(123L, response.getCommentId()); + assertEquals("좋은 정보 감사합니다!", response.getCommentContent()); + assertEquals(userId.toString(), response.getUserId()); + assertEquals("멍멍이집사", response.getNickname()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(commentRepository).save(captor.capture()); + assertEquals(board, captor.getValue().getBoard()); + assertEquals(user, captor.getValue().getUser()); + assertNull(captor.getValue().getParentComment()); + assertEquals("좋은 정보 감사합니다!", captor.getValue().getCommentContent()); + + log.info("테스트 종료: 댓글 작성 성공"); + } + + /** + * 부모 댓글 ID가 전달되면 같은 게시글의 댓글을 부모 댓글로 연결하는지 검증합니다. + */ + @Test + @DisplayName("대댓글 작성 성공: 부모 댓글이 같은 게시글에 있으면 대댓글로 저장한다.") + void createComment_Success_WithParentComment() { + log.info("테스트 시작: 대댓글 작성 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + Long parentCommentId = 10L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + given(user.getNickname()).willReturn("멍멍이집사"); + + Board board = Board.builder() + .boardId(boardId) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + Comment parentComment = Comment.builder() + .commentId(parentCommentId) + .board(board) + .user(user) + .commentContent("부모 댓글") + .build(); + + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(boardId) + .commentContent("대댓글입니다.") + .parentCommentId(parentCommentId) + .build(); + + Comment savedComment = Comment.builder() + .commentId(123L) + .board(board) + .user(user) + .parentComment(parentComment) + .commentContent(request.getCommentContent()) + .build(); + + given(boardService.getBoardById(boardId)).willReturn(board); + given(userService.getUserById(userId)).willReturn(user); + given(commentRepository.findById(parentCommentId)).willReturn(Optional.of(parentComment)); + given(commentRepository.save(any(Comment.class))).willReturn(savedComment); + + // when + CommentCreateResponse response = commentService.createComment(userId, request); + + // then + assertNotNull(response); + assertEquals(123L, response.getCommentId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(commentRepository).save(captor.capture()); + assertEquals(parentComment, captor.getValue().getParentComment()); + + log.info("테스트 종료: 대댓글 작성 성공"); + } + + /** + * 댓글 목록 조회 시 댓글 목록과 페이지 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 목록 조회 성공: 댓글 목록과 페이지 정보를 반환한다.") + void getComments_Success() { + log.info("테스트 시작: 댓글 목록 조회 성공"); + + // given + Long boardId = 1L; + Board board = Board.builder() + .boardId(boardId) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + CommentListQueryResponse queryResponse = CommentListQueryResponse.builder() + .commentId(102L) + .parentCommentId(null) + .commentContent("두 번째 댓글입니다.") + .userId("uuid-user-2") + .nickname("산책왕뽀삐") + .createdAt(LocalDateTime.of(2025, 10, 14, 14, 30)) + .build(); + + given(boardService.getBoardById(boardId)).willReturn(board); + given(commentMapper.findCommentsByBoardId(boardId, 0, 10)).willReturn(List.of(queryResponse)); + given(commentMapper.countCommentsByBoardId(boardId)).willReturn(1L); + + // when + CommentListResponse response = commentService.getComments(boardId, 0, 10); + + // then + assertNotNull(response); + assertEquals("댓글 목록을 성공적으로 조회했습니다.", response.getMessage()); + assertEquals(1, response.getData().size()); + assertEquals(102L, response.getData().get(0).getCommentId()); + assertEquals(1L, response.getPageInfo().getTotalElements()); + assertEquals(1, response.getPageInfo().getTotalPages()); + + verify(boardService).getBoardById(boardId); + verify(commentMapper).findCommentsByBoardId(boardId, 0, 10); + verify(commentMapper).countCommentsByBoardId(boardId); + + log.info("테스트 종료: 댓글 목록 조회 성공"); + } + + /** + * 댓글 목록 조회 시 잘못된 페이지 크기이면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("댓글 목록 조회 실패: 페이지 크기가 100보다 크면 예외가 발생한다.") + void getComments_Fail_InvalidSize() { + log.info("테스트 시작: 댓글 목록 조회 실패 - 잘못된 페이지 크기"); + + // when + CommentException exception = assertThrows(CommentException.class, + () -> commentService.getComments(1L, 0, 101)); + + // then + assertEquals(CommentErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(commentMapper, never()).findCommentsByBoardId(1L, 0, 101); + + log.info("테스트 종료: 댓글 목록 조회 실패 - 잘못된 페이지 크기"); + } + + /** + * 댓글 작성자 본인이 댓글을 수정하면 Mapper를 통해 댓글 내용이 수정되는지 검증합니다. + */ + @Test + @DisplayName("댓글 수정 성공: 작성자 본인이 댓글을 수정한다.") + void updateComment_Success() { + log.info("테스트 시작: 댓글 수정 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + + Comment comment = Comment.builder() + .commentId(commentId) + .user(user) + .commentContent("기존 댓글") + .build(); + + CommentUpdateRequest request = CommentUpdateRequest.builder() + .commentContent("수정입니다.") + .build(); + + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + CommentSimpleResponse response = commentService.updateComment(userId, commentId, request); + + // then + assertEquals("댓글이 성공적으로 수정되었습니다.", response.getMessage()); + verify(commentRepository).findById(commentId); + verify(commentMapper).updateComment(commentId, "수정입니다."); + + log.info("테스트 종료: 댓글 수정 성공"); + } + + /** + * 댓글 작성자가 아닌 사용자가 댓글을 수정하면 권한 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("댓글 수정 실패: 작성자가 아니면 예외가 발생한다.") + void updateComment_Fail_PermissionDenied() { + log.info("테스트 시작: 댓글 수정 실패 - 권한 없음"); + + // given + UUID ownerId = UUID.randomUUID(); + UUID requestUserId = UUID.randomUUID(); + Long commentId = 123L; + User owner = mock(User.class); + given(owner.getUsersId()).willReturn(ownerId); + + Comment comment = Comment.builder() + .commentId(commentId) + .user(owner) + .commentContent("기존 댓글") + .build(); + + CommentUpdateRequest request = CommentUpdateRequest.builder() + .commentContent("수정입니다.") + .build(); + + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + CommentException exception = assertThrows(CommentException.class, + () -> commentService.updateComment(requestUserId, commentId, request)); + + // then + assertEquals(CommentErrorCode.UPDATE_PERMISSION_DENIED, exception.getErrorCode()); + verify(commentRepository).findById(commentId); + verify(commentMapper, never()).updateComment(commentId, "수정입니다."); + + log.info("테스트 종료: 댓글 수정 실패 - 권한 없음"); + } + + /** + * 댓글 작성자 본인이 댓글을 삭제하면 Repository를 통해 댓글이 삭제되는지 검증합니다. + */ + @Test + @DisplayName("댓글 삭제 성공: 작성자 본인이 댓글을 삭제한다.") + void deleteComment_Success() { + log.info("테스트 시작: 댓글 삭제 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + + Comment comment = Comment.builder() + .commentId(commentId) + .user(user) + .commentContent("삭제할 댓글") + .build(); + + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + CommentSimpleResponse response = commentService.deleteComment(userId, commentId); + + // then + assertEquals("댓글이 성공적으로 삭제되었습니다.", response.getMessage()); + verify(commentRepository).findById(commentId); + verify(commentRepository).delete(comment); + + log.info("테스트 종료: 댓글 삭제 성공"); + } + + /** + * 삭제할 댓글이 존재하지 않으면 댓글 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("댓글 삭제 실패: 댓글을 찾을 수 없으면 예외가 발생한다.") + void deleteComment_Fail_CommentNotFound() { + log.info("테스트 시작: 댓글 삭제 실패 - 댓글 없음"); + + // given + UUID userId = UUID.randomUUID(); + Long commentId = 999L; + given(commentRepository.findById(commentId)).willReturn(Optional.empty()); + + // when + CommentException exception = assertThrows(CommentException.class, + () -> commentService.deleteComment(userId, commentId)); + + // then + assertEquals(CommentErrorCode.COMMENT_NOT_FOUND, exception.getErrorCode()); + verify(commentRepository).findById(commentId); + + log.info("테스트 종료: 댓글 삭제 실패 - 댓글 없음"); + } +} From 1b4b6d10980137461ddc3a73523e6681515a8165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=ED=98=84=EB=B9=88?= <63556578+limhb708@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:30:55 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/controller/BoardController.java | 47 +- .../board/dto/request/BoardRequest.java | 6 +- .../board/dto/response/BoardResponse.java | 233 ++++++++- .../backend/board/mapper/BoardMapper.java | 22 + .../backend/board/service/BoardService.java | 17 +- .../board/service/BoardServiceImpl.java | 75 ++- .../comment/controller/CommentController.java | 175 +++++++ .../comment/dto/request/CommentRequest.java | 73 +++ .../comment/dto/response/CommentResponse.java | 296 ++++++++++++ .../dodo/backend/comment/entity/Comment.java | 68 +++ .../comment/exception/CommentErrorCode.java | 54 +++ .../comment/exception/CommentException.java | 17 + .../backend/comment/mapper/CommentMapper.java | 47 ++ .../comment/repository/CommentRepository.java | 12 + .../comment/service/CommentService.java | 57 +++ .../comment/service/CommentServiceImpl.java | 236 +++++++++ .../controller/TestWebSocketController.java | 4 +- .../dodo/backend/board/mapper/BoardMapper.xml | 63 +++ .../templates/websocket/socket-test.html | 446 ++++++++++-------- .../board/controller/BoardControllerTest.java | 151 ++++++ .../board/service/BoardServiceTest.java | 195 +++++++- .../controller/CommentControllerTest.java | 210 +++++++++ .../comment/service/CommentServiceTest.java | 385 +++++++++++++++ 23 files changed, 2649 insertions(+), 240 deletions(-) create mode 100644 src/main/java/com/dodo/backend/comment/controller/CommentController.java create mode 100644 src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java create mode 100644 src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java create mode 100644 src/main/java/com/dodo/backend/comment/entity/Comment.java create mode 100644 src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java create mode 100644 src/main/java/com/dodo/backend/comment/exception/CommentException.java create mode 100644 src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java create mode 100644 src/main/java/com/dodo/backend/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/dodo/backend/comment/service/CommentService.java create mode 100644 src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java create mode 100644 src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java create mode 100644 src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index ed84209..f2c4de7 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import java.util.UUID; @@ -43,6 +44,36 @@ public class BoardController { private final BoardService boardService; + /** + * 공개 상태의 게시글 목록을 조회합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 게시글 목록 조회 응답 + */ + @Operation(summary = "게시글 목록 조회", description = "공개 상태의 게시글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "게시글 목록 조회를 성공했습니다.", + content = @Content(schema = @Schema(implementation = BoardListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping + public ResponseEntity getBoardList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("게시글 목록 조회 요청 수신 - User: {}, Page: {}, Size: {}", userId, page, size); + + return ResponseEntity.ok(boardService.getBoardList(page, size)); + } /** * 새 게시글을 작성합니다. * @@ -79,14 +110,13 @@ public ResponseEntity createBoard( } /** - * 수정 중인 게시글 내용을 Redis에 임시 저장합니다. + * 작성 중인 게시글 내용을 Redis에 임시 저장합니다. * - * @param boardId 임시 저장 대상 게시글 ID * @param request 임시 저장 요청 DTO * @param userDetails 인증된 사용자 정보 * @return 임시 저장 세션 키와 성공 메시지 */ - @Operation(summary = "게시글 임시 저장", description = "수정 중인 게시글 내용을 Redis에 임시 저장합니다.") + @Operation(summary = "게시글 임시 저장", description = "작성 중인 게시글의 제목, 본문, 이미지 URL 목록을 Redis에 임시 저장합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "게시글이 성공적으로 임시 저장되었습니다.", content = @Content(schema = @Schema(implementation = BoardResponse.BoardTempSaveResponse.class))), @@ -94,23 +124,18 @@ public ResponseEntity createBoard( content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "403", description = "게시글을 수정할 권한이 없습니다.", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping("/temp-save") public ResponseEntity tempSaveBoard( - @RequestParam Long boardId, @RequestBody BoardTempSaveRequest request, @AuthenticationPrincipal UserDetails userDetails ) { UUID userId = UUID.fromString(userDetails.getUsername()); - log.info("게시글 임시 저장 요청 수신 - User: {}, BoardId: {}", userId, boardId); + log.info("게시글 임시 저장 요청 수신 - User: {}", userId); - return ResponseEntity.ok(boardService.tempSaveBoard(userId, boardId, request)); + return ResponseEntity.ok(boardService.tempSaveBoard(userId, request)); } /** @@ -118,7 +143,7 @@ public ResponseEntity tempSaveBoard( * * @param sessionKey 임시 저장 데이터의 세션 키 * @param userDetails 인증된 사용자 정보 - * @return 임시 저장된 게시글 제목, 본문, 이미지 URL + * @return 임시 저장된 게시글 제목, 본문, 이미지 URL 목록 */ @Operation(summary = "임시 저장 게시글 조회", description = "sessionKey 기준으로 Redis에 저장된 임시 게시글 내용을 조회합니다.") @ApiResponses(value = { diff --git a/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java b/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java index 9bb082a..8a461a0 100644 --- a/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java +++ b/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java @@ -86,7 +86,7 @@ public static class BoardUpdateRequest { /** * 게시글 임시 저장 요청 DTO입니다. *

- * Redis에 저장할 제목, 본문, 단일 이미지 URL을 담습니다. + * Redis에 저장할 제목, 본문, 이미지 URL 목록을 담습니다. */ @Getter @Builder @@ -101,7 +101,7 @@ public static class BoardTempSaveRequest { @Schema(description = "임시 저장 게시글 내용", example = "임시 저장 내용") private String boardContent; - @Schema(description = "임시 저장 게시글 이미지 URL", example = "https://example.com/images/bori.jpg") - private String imageFileUrl; + @Schema(description = "임시 저장 게시글 이미지 URL 목록", example = "[\"https://example.com/images/bori_1.jpg\", \"https://example.com/images/bori_2.jpg\"]") + private List imageFileUrls; } } diff --git a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java index 42e4811..f6da007 100644 --- a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java +++ b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import java.time.LocalDateTime; import java.util.List; @@ -46,6 +47,234 @@ public static BoardCreateResponse toDto(Long boardId, String message) { } } + /** + * 게시글 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "게시글 목록 조회 응답") + public static class BoardListResponse { + + @Schema(description = "응답 메시지", example = "게시글 목록 조회를 성공했습니다.") + private String message; + + @Schema(description = "게시글 목록") + private List boards; + + @Schema(description = "전체 페이지 수", example = "1") + private int totalPages; + + @Schema(description = "전체 게시글 수", example = "1") + private long totalElements; + + @Schema(description = "현재 페이지 번호", example = "0") + private int currentPage; + + @Schema(description = "페이지 크기", example = "10") + private int pageSize; + + /** + * MyBatis 조회 결과를 게시글 목록 조회 응답 DTO로 변환합니다. + * + * @param queryResponses 게시글 목록 조회 결과 + * @param totalElements 전체 게시글 수 + * @param currentPage 현재 페이지 번호 + * @param pageSize 페이지 크기 + * @param message 응답 메시지 + * @return 게시글 목록 조회 응답 DTO + */ + public static BoardListResponse toDto( + List queryResponses, + long totalElements, + int currentPage, + int pageSize, + String message + ) { + List boards = queryResponses == null + ? List.of() + : queryResponses.stream() + .map(BoardListItemResponse::toDto) + .toList(); + + int totalPages = totalElements == 0 + ? 0 + : (int) Math.ceil((double) totalElements / pageSize); + + return BoardListResponse.builder() + .message(message) + .boards(boards) + .totalPages(totalPages) + .totalElements(totalElements) + .currentPage(currentPage) + .pageSize(pageSize) + .build(); + } + } + + /** + * 게시글 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "게시글 목록 아이템 응답") + public static class BoardListItemResponse { + + private static final int CONTENT_PREVIEW_LENGTH = 20; + + @Schema(description = "게시글 ID", example = "1") + private Long boardId; + + @Schema(description = "게시글 제목", example = "우리 강아지 자랑합니다") + private String boardTitle; + + @Schema(description = "게시글 본문 20자 미리보기", example = "오늘 산책하다가 찍은 사진이에요") + private String boardContentPreview; + + @Schema(description = "대표 이미지 URL", example = "https://example.com/images/bori_1.jpg") + private String thumbnailImageUrl; + + @Schema(description = "작성자 닉네임", example = "자유로운산책") + private String nickname; + + @Schema(description = "조회수", example = "51") + private Integer viewCount; + + @Schema(description = "댓글 수", example = "3") + private Long commentCount; + + @Schema(description = "좋아요 수", example = "12") + private Long likeCount; + + @Schema(description = "싫어요 수", example = "1") + private Long dislikeCount; + + @Schema(description = "게시글 생성 일시", example = "2026-01-31T13:52:32.68613") + private LocalDateTime createdAt; + + @Schema(description = "게시글 수정 일시", example = "2026-01-31T14:10:12.12345") + private LocalDateTime modifiedAt; + + /** + * 게시글 목록 조회 결과를 게시글 목록 아이템 응답 DTO로 변환합니다. + * + * @param queryResponse 게시글 목록 조회 결과 + * @return 게시글 목록 아이템 응답 DTO + */ + public static BoardListItemResponse toDto(BoardListQueryResponse queryResponse) { + return BoardListItemResponse.builder() + .boardId(queryResponse.getBoardId()) + .boardTitle(queryResponse.getBoardTitle()) + .boardContentPreview(createContentPreview(queryResponse.getBoardContent())) + .thumbnailImageUrl(queryResponse.getThumbnailImageUrl()) + .nickname(queryResponse.getNickname()) + .viewCount(queryResponse.getViewCount()) + .commentCount(defaultZero(queryResponse.getCommentCount())) + .likeCount(defaultZero(queryResponse.getLikeCount())) + .dislikeCount(defaultZero(queryResponse.getDislikeCount())) + .createdAt(queryResponse.getCreatedAt()) + .modifiedAt(queryResponse.getModifiedAt()) + .build(); + } + + /** + * 게시글 본문을 20자 미리보기 문자열로 변환합니다. + * + * @param boardContent 게시글 본문 + * @return 게시글 본문 20자 미리보기 + */ + private static String createContentPreview(String boardContent) { + if (boardContent == null) { + return ""; + } + + if (boardContent.length() <= CONTENT_PREVIEW_LENGTH) { + return boardContent; + } + + return boardContent.substring(0, CONTENT_PREVIEW_LENGTH); + } + + /** + * 숫자 값이 null이면 0을 반환합니다. + * + * @param value 변환할 숫자 값 + * @return null이 아닌 숫자 값 + */ + private static Long defaultZero(Long value) { + return value == null ? 0L : value; + } + } + + /** + * 게시글 목록 조회 MyBatis 결과 DTO입니다. + */ + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class BoardListQueryResponse { + + /** + * 게시글 ID입니다. + */ + private Long boardId; + + /** + * 게시글 제목입니다. + */ + private String boardTitle; + + /** + * 게시글 본문입니다. + */ + private String boardContent; + + /** + * 대표 이미지 URL입니다. + */ + private String thumbnailImageUrl; + + /** + * 작성자 닉네임입니다. + */ + private String nickname; + + /** + * 조회수입니다. + */ + private Integer viewCount; + + /** + * 댓글 수입니다. + */ + private Long commentCount; + + /** + * 좋아요 수입니다. + */ + private Long likeCount; + + /** + * 싫어요 수입니다. + */ + private Long dislikeCount; + + /** + * 게시글 생성 일시입니다. + */ + private LocalDateTime createdAt; + + /** + * 게시글 수정 일시입니다. + */ + private LocalDateTime modifiedAt; + } + /** * 게시글 상세 조회 응답 DTO입니다. */ @@ -195,7 +424,7 @@ public static class BoardTempSaveDetailResponse { @Schema(description = "임시 저장 게시글 내용", example = "임시 저장 내용") private String boardContent; - @Schema(description = "임시 저장 게시글 이미지 URL", example = "https://example.com/images/bori.jpg") - private String imageFileUrl; + @Schema(description = "임시 저장 게시글 이미지 URL 목록", example = "[\"https://example.com/images/bori_1.jpg\", \"https://example.com/images/bori_2.jpg\"]") + private List imageFileUrls; } } diff --git a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java index 5da2bf1..a97ac8d 100644 --- a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java +++ b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java @@ -1,15 +1,37 @@ package com.dodo.backend.board.mapper; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.List; + /** * 게시글(Board) 도메인의 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. */ @Mapper public interface BoardMapper { + /** + * 공개 상태의 게시글 목록을 페이지 단위로 조회합니다. + * + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 게시글 목록 조회 결과 + */ + List findBoardList( + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 공개 상태의 게시글 전체 개수를 조회합니다. + * + * @return 공개 게시글 전체 개수 + */ + long countPublishedBoards(); + /** * 게시글 제목과 내용을 선택적으로 수정합니다. * diff --git a/src/main/java/com/dodo/backend/board/service/BoardService.java b/src/main/java/com/dodo/backend/board/service/BoardService.java index b6b9f5b..9056f15 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -4,6 +4,7 @@ import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardSimpleResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; @@ -26,6 +27,15 @@ public interface BoardService { */ Board getBoardById(Long boardId); + /** + * 공개 상태의 게시글 목록을 조회합니다. + * + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 게시글 목록 조회 응답 DTO + */ + BoardListResponse getBoardList(int page, int size); + /** * 새 게시글을 생성하고 이미지 URL 목록을 게시글에 연결합니다. * @@ -59,15 +69,14 @@ public interface BoardService { BoardSimpleResponse updateBoard(UUID userId, Long boardId, BoardUpdateRequest request); /** - * 수정 중인 게시글 내용을 Redis에 임시 저장합니다. + * 작성 중인 게시글의 제목, 본문, 이미지 URL 목록을 Redis에 임시 저장합니다. * * @param userId 요청 사용자 ID - * @param boardId 임시 저장 대상 게시글 ID * @param request 게시글 임시 저장 요청 DTO * @return 임시 저장 세션 키가 포함된 응답 DTO - * @throws BoardException 잘못된 요청, 게시글 없음, 임시 저장 권한 없음인 경우 + * @throws BoardException 잘못된 요청인 경우 */ - BoardTempSaveResponse tempSaveBoard(UUID userId, Long boardId, BoardTempSaveRequest request); + BoardTempSaveResponse tempSaveBoard(UUID userId, BoardTempSaveRequest request); /** * Redis에 임시 저장된 게시글 내용을 조회합니다. diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index 0cadbd5..73cc6cf 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -4,6 +4,8 @@ import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardSimpleResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; @@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -54,6 +57,8 @@ public class BoardServiceImpl implements BoardService { */ private static final long TEMP_SAVE_TTL_DAYS = 7L; + private static final int MAX_BOARD_LIST_SIZE = 100; + /** * 게시글 저장 및 단건 조회를 처리하는 JPA Repository입니다. */ @@ -94,6 +99,31 @@ public Board getBoardById(Long boardId) { .orElseThrow(() -> new BoardException(BOARD_NOT_FOUND)); } + /** + * 공개 상태의 게시글 목록을 조회합니다. + * + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 게시글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public BoardListResponse getBoardList(int page, int size) { + validateBoardListRequest(page, size); + + int offset = page * size; + List queryResponses = boardMapper.findBoardList(offset, size); + long totalElements = boardMapper.countPublishedBoards(); + + return BoardListResponse.toDto( + queryResponses, + totalElements, + page, + size, + "게시글 목록 조회를 성공했습니다." + ); + } + /** * 새 게시글을 생성하고 요청에 포함된 이미지 URL 목록을 게시글에 연결합니다. * @@ -190,38 +220,29 @@ public BoardSimpleResponse updateBoard(UUID userId, Long boardId, BoardUpdateReq } /** - * 수정 중인 게시글 내용을 Redis에 임시 저장합니다. + * 작성 중인 게시글의 제목, 본문, 이미지 URL 목록을 Redis에 임시 저장합니다. *

- * 게시글 작성자만 임시 저장할 수 있으며 삭제된 게시글은 임시 저장할 수 없습니다. + * 게시글 ID를 요구하지 않으며, 아직 DB에 생성되지 않은 작성 중 초안도 저장할 수 있습니다. * 저장 데이터에는 사용자 ID를 함께 보관하여 조회 시 세션 키 소유자를 검증합니다. * * @param userId 요청 사용자 ID - * @param boardId 임시 저장 대상 게시글 ID * @param request 게시글 임시 저장 요청 DTO * @return 임시 저장 세션 키가 포함된 응답 DTO - * @throws BoardException 잘못된 요청, 게시글 없음, 임시 저장 권한 없음인 경우 + * @throws BoardException 저장할 필드가 없는 잘못된 요청인 경우 */ @Transactional @Override - public BoardTempSaveResponse tempSaveBoard(UUID userId, Long boardId, BoardTempSaveRequest request) { + public BoardTempSaveResponse tempSaveBoard(UUID userId, BoardTempSaveRequest request) { if (request == null || !hasTempSaveFields(request)) { throw new BoardException(INVALID_REQUEST); } - Board board = findBoardById(boardId); - validateBoardOwner(userId, board, TEMP_SAVE_PERMISSION_DENIED); - - if (board.getBoardStatus() == BoardStatus.DELETED) { - throw new BoardException(TEMP_SAVE_PERMISSION_DENIED); - } - String sessionKey = UUID.randomUUID().toString(); Map tempSaveData = new HashMap<>(); tempSaveData.put("userId", userId.toString()); - tempSaveData.put("boardId", boardId); tempSaveData.put("boardTitle", request.getBoardTitle()); tempSaveData.put("boardContent", request.getBoardContent()); - tempSaveData.put("imageFileUrl", request.getImageFileUrl()); + tempSaveData.put("imageFileUrls", request.getImageFileUrls()); redisTemplate.opsForValue().set( TEMP_SAVE_KEY_PREFIX + sessionKey, @@ -265,7 +286,7 @@ public BoardTempSaveDetailResponse getTempSavedBoard(UUID userId, String session .message("임시 저장된 게시글을 성공적으로 불러왔습니다.") .boardTitle(toNullableString(tempSaveData.get("boardTitle"))) .boardContent(toNullableString(tempSaveData.get("boardContent"))) - .imageFileUrl(toNullableString(tempSaveData.get("imageFileUrl"))) + .imageFileUrls(toStringList(tempSaveData.get("imageFileUrls"))) .build(); } @@ -311,6 +332,18 @@ private Board findBoardById(Long boardId) { .orElseThrow(() -> new BoardException(BOARD_NOT_FOUND)); } + /** + * 게시글 목록 조회 요청 값을 검증합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + */ + private void validateBoardListRequest(int page, int size) { + if (page < 0 || size <= 0 || size > MAX_BOARD_LIST_SIZE || page > Integer.MAX_VALUE / size) { + throw new BoardException(INVALID_REQUEST); + } + } + /** * 요청 사용자가 게시글 작성자인지 검증합니다. * @@ -359,7 +392,17 @@ private boolean hasImageUpdateFields(BoardUpdateRequest request) { private boolean hasTempSaveFields(BoardTempSaveRequest request) { return request.getBoardTitle() != null || request.getBoardContent() != null - || request.getImageFileUrl() != null; + || request.getImageFileUrls() != null; + } + + private List toStringList(Object value) { + if (!(value instanceof List values)) { + return null; + } + + return values.stream() + .map(String::valueOf) + .toList(); } /** diff --git a/src/main/java/com/dodo/backend/comment/controller/CommentController.java b/src/main/java/com/dodo/backend/comment/controller/CommentController.java new file mode 100644 index 0000000..661af80 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/controller/CommentController.java @@ -0,0 +1,175 @@ +package com.dodo.backend.comment.controller; + +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.service.CommentService; +import com.dodo.backend.common.exception.ErrorResponse; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +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.util.UUID; + +/** + * 댓글 작성, 목록 조회, 수정, 삭제 API를 제공하는 컨트롤러입니다. + *

+ * 인증된 사용자의 UUID는 {@link UserDetails#getUsername()}에서 추출하여 서비스 계층으로 전달합니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments") +@Tag(name = "Comment API", description = "댓글 관련 API") +@Slf4j +public class CommentController { + + private final CommentService commentService; + + /** + * 댓글을 작성합니다. + * + * @param request 댓글 작성 요청 DTO + * @param userDetails 인증된 사용자 정보 + * @return 댓글 작성 응답 + */ + @Operation(summary = "댓글 작성", description = "게시글에 댓글 또는 대댓글을 작성합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 작성되었습니다.", + content = @Content(schema = @Schema(implementation = CommentCreateResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + public ResponseEntity createComment( + @Valid @RequestBody CommentCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 작성 요청 수신 - User: {}, BoardId: {}", userId, request.getBoardId()); + + return ResponseEntity.ok(commentService.createComment(userId, request)); + } + + /** + * 특정 게시글의 댓글 목록을 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 댓글 목록 조회 응답 + */ + @Operation(summary = "댓글 목록 조회", description = "boardId 기준으로 댓글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글 목록을 성공적으로 조회했습니다.", + content = @Content(schema = @Schema(implementation = CommentListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/{boardId}") + public ResponseEntity getComments( + @PathVariable Long boardId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 목록 조회 요청 수신 - User: {}, BoardId: {}, Page: {}, Size: {}", userId, boardId, page, size); + + return ResponseEntity.ok(commentService.getComments(boardId, page, size)); + } + + /** + * 특정 댓글을 수정합니다. + * + * @param commentId 수정할 댓글 ID + * @param request 댓글 수정 요청 DTO + * @param userDetails 인증된 사용자 정보 + * @return 댓글 수정 응답 + */ + @Operation(summary = "댓글 수정", description = "commentId 기준으로 댓글 내용을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 수정되었습니다.", + content = @Content(schema = @Schema(implementation = CommentSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "댓글을 수정할 권한이 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "해당 ID의 댓글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PatchMapping("/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 수정 요청 수신 - User: {}, CommentId: {}", userId, commentId); + + return ResponseEntity.ok(commentService.updateComment(userId, commentId, request)); + } + + /** + * 특정 댓글을 삭제 상태로 변경합니다. + * + * @param commentId 삭제할 댓글 ID + * @param userDetails 인증된 사용자 정보 + * @return 댓글 삭제 응답 + */ + @Operation(summary = "댓글 삭제", description = "commentId 기준으로 댓글을 삭제 상태로 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = CommentSimpleResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "특정 댓글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 삭제 요청 수신 - User: {}, CommentId: {}", userId, commentId); + + return ResponseEntity.ok(commentService.deleteComment(userId, commentId)); + } +} diff --git a/src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java b/src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java new file mode 100644 index 0000000..1596077 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/dto/request/CommentRequest.java @@ -0,0 +1,73 @@ +package com.dodo.backend.comment.dto.request; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 댓글 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "댓글 요청 DTO 그룹") +public class CommentRequest { + + /** + * 댓글 작성 요청 DTO입니다. + */ + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "댓글 작성 요청") + public static class CommentCreateRequest { + + @NotNull(message = "게시글 ID는 필수입니다.") + @Schema(description = "댓글을 작성할 게시글 ID", example = "1") + private Long boardId; + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다!") + private String commentContent; + + @Schema(description = "부모 댓글 ID", example = "null") + private Long parentCommentId; + + /** + * 댓글 작성 요청 정보를 {@link Comment} 엔티티로 변환합니다. + * + * @param board 댓글이 작성될 게시글 + * @param user 댓글 작성자 + * @param parentComment 부모 댓글 + * @return 댓글 엔티티 + */ + public Comment toEntity(Board board, User user, Comment parentComment) { + return Comment.builder() + .board(board) + .user(user) + .parentComment(parentComment) + .commentContent(this.commentContent) + .build(); + } + } + + /** + * 댓글 수정 요청 DTO입니다. + */ + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "댓글 수정 요청") + public static class CommentUpdateRequest { + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Schema(description = "수정할 댓글 내용", example = "수정입니다.") + private String commentContent; + } +} diff --git a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..c68110c --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java @@ -0,0 +1,296 @@ +package com.dodo.backend.comment.dto.response; + +import com.dodo.backend.comment.entity.Comment; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 댓글 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "댓글 응답 DTO 그룹") +public class CommentResponse { + + /** + * 댓글 작성 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 작성 응답") + public static class CommentCreateResponse { + + @Schema(description = "응답 메시지", example = "댓글이 성공적으로 작성되었습니다.") + private String message; + + @Schema(description = "댓글 ID", example = "123") + private Long commentId; + + @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다!") + private String commentContent; + + @Schema(description = "작성자 ID", example = "uuid-user-1") + private String userId; + + @Schema(description = "작성자 닉네임", example = "멍멍이집사") + private String nickname; + + /** + * 댓글 엔티티를 댓글 작성 응답 DTO로 변환합니다. + * + * @param comment 댓글 엔티티 + * @param message 응답 메시지 + * @return 댓글 작성 응답 DTO + */ + public static CommentCreateResponse toDto(Comment comment, String message) { + return CommentCreateResponse.builder() + .message(message) + .commentId(comment.getCommentId()) + .commentContent(comment.getCommentContent()) + .userId(comment.getUser().getUsersId().toString()) + .nickname(comment.getUser().getNickname()) + .build(); + } + } + + /** + * 댓글 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 목록 조회 응답") + public static class CommentListResponse { + + @Schema(description = "응답 메시지", example = "댓글 목록을 성공적으로 조회했습니다.") + private String message; + + @Schema(description = "페이지 정보") + private PageInfoResponse pageInfo; + + @Schema(description = "댓글 목록") + private List data; + + /** + * MyBatis 조회 결과를 댓글 목록 조회 응답 DTO로 변환합니다. + * + * @param queryResponses 댓글 목록 조회 결과 + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 댓글 수 + * @param message 응답 메시지 + * @return 댓글 목록 조회 응답 DTO + */ + public static CommentListResponse toDto( + List queryResponses, + int page, + int size, + long totalElements, + String message + ) { + List data = queryResponses == null + ? List.of() + : queryResponses.stream() + .map(CommentItemResponse::toDto) + .toList(); + + return CommentListResponse.builder() + .message(message) + .pageInfo(PageInfoResponse.toDto(page, size, totalElements)) + .data(data) + .build(); + } + } + + /** + * 페이지 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "페이지 정보 응답") + public static class PageInfoResponse { + + @Schema(description = "현재 페이지 번호", example = "0") + private int page; + + @Schema(description = "페이지 크기", example = "10") + private int size; + + @Schema(description = "전체 요소 수", example = "48") + private long totalElements; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + /** + * 페이지 정보 응답 DTO를 생성합니다. + * + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 요소 수 + * @return 페이지 정보 응답 DTO + */ + public static PageInfoResponse toDto(int page, int size, long totalElements) { + int totalPages = totalElements == 0 + ? 0 + : (int) Math.ceil((double) totalElements / size); + + return PageInfoResponse.builder() + .page(page) + .size(size) + .totalElements(totalElements) + .totalPages(totalPages) + .build(); + } + } + + /** + * 댓글 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 목록 아이템 응답") + public static class CommentItemResponse { + + @Schema(description = "댓글 ID", example = "102") + private Long commentId; + + @Schema(description = "부모 댓글 ID", example = "null") + private Long parentCommentId; + + @Schema(description = "댓글 내용", example = "두 번째 댓글입니다.") + private String commentContent; + + @Schema(description = "작성자 정보") + private CommentAuthorResponse author; + + @Schema(description = "댓글 작성 일시", example = "2025-10-14T14:30:00") + private LocalDateTime createdAt; + + /** + * 댓글 목록 조회 결과를 댓글 목록 아이템 응답 DTO로 변환합니다. + * + * @param queryResponse 댓글 목록 조회 결과 + * @return 댓글 목록 아이템 응답 DTO + */ + public static CommentItemResponse toDto(CommentListQueryResponse queryResponse) { + return CommentItemResponse.builder() + .commentId(queryResponse.getCommentId()) + .parentCommentId(queryResponse.getParentCommentId()) + .commentContent(queryResponse.getCommentContent()) + .author(CommentAuthorResponse.toDto(queryResponse.getUserId(), queryResponse.getNickname())) + .createdAt(queryResponse.getCreatedAt()) + .build(); + } + } + + /** + * 댓글 작성자 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 작성자 응답") + public static class CommentAuthorResponse { + + @Schema(description = "작성자 ID", example = "uuid-user-1") + private String userId; + + @Schema(description = "작성자 닉네임", example = "행복한강아지") + private String nickname; + + /** + * 댓글 작성자 응답 DTO를 생성합니다. + * + * @param userId 작성자 ID + * @param nickname 작성자 닉네임 + * @return 댓글 작성자 응답 DTO + */ + public static CommentAuthorResponse toDto(String userId, String nickname) { + return CommentAuthorResponse.builder() + .userId(userId) + .nickname(nickname) + .build(); + } + } + + /** + * 댓글 목록 조회 MyBatis 결과 DTO입니다. + */ + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class CommentListQueryResponse { + + /** + * 댓글 ID입니다. + */ + private Long commentId; + + /** + * 부모 댓글 ID입니다. + */ + private Long parentCommentId; + + /** + * 댓글 내용입니다. + */ + private String commentContent; + + /** + * 작성자 ID입니다. + */ + private String userId; + + /** + * 작성자 닉네임입니다. + */ + private String nickname; + + /** + * 댓글 작성 일시입니다. + */ + private LocalDateTime createdAt; + } + + /** + * 댓글 수정, 삭제처럼 메시지만 반환하는 단순 처리 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "댓글 단순 처리 응답") + public static class CommentSimpleResponse { + + @Schema(description = "응답 메시지", example = "댓글이 성공적으로 수정되었습니다.") + private String message; + + /** + * 댓글 단순 처리 응답 DTO를 생성합니다. + * + * @param message 응답 메시지 + * @return 댓글 단순 처리 응답 DTO + */ + public static CommentSimpleResponse toDto(String message) { + return CommentSimpleResponse.builder() + .message(message) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/comment/entity/Comment.java b/src/main/java/com/dodo/backend/comment/entity/Comment.java new file mode 100644 index 0000000..2b7f063 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/entity/Comment.java @@ -0,0 +1,68 @@ +package com.dodo.backend.comment.entity; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 댓글(Comment) 정보를 관리하는 엔티티입니다. + *

+ * 데이터베이스 {@code comment} 테이블과 매핑되며, 게시글, 작성자, 부모 댓글, + * 댓글 내용, 생성일 및 수정일 정보를 포함합니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table(name = "comment") +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long commentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id", nullable = false) + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parentComment; + + @Column(name = "comment_content", columnDefinition = "TEXT", nullable = false) + private String commentContent; + + @CreatedDate + @Column(name = "comment_created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "comment_modified_at") + private LocalDateTime modifiedAt; +} diff --git a/src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java b/src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..65323ec --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/exception/CommentErrorCode.java @@ -0,0 +1,54 @@ +package com.dodo.backend.comment.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 댓글(Comment) 도메인에서 발생하는 예외 상황을 관리하는 에러 코드 정의 클래스입니다. + */ +@AllArgsConstructor +@Getter +public enum CommentErrorCode implements BaseErrorCode { + + /** + * 클라이언트 요청이 잘못되었을 때 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 인증되지 않은 사용자가 댓글 API를 요청했을 때 사용합니다. + */ + LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "로그인이 필요한 기능입니다."), + + /** + * 요청한 댓글을 찾을 수 없을 때 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 댓글을 찾을 수 없습니다."), + + /** + * 댓글 수정 권한이 없을 때 사용합니다. + */ + UPDATE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "댓글을 수정할 권한이 없습니다."), + + /** + * 댓글 삭제 권한이 없을 때 사용합니다. + */ + DELETE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "댓글을 삭제할 권한이 없습니다."), + + /** + * 서버 내부에서 예상치 못한 오류가 발생했을 때 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * 에러 상황에 해당하는 HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 클라이언트에게 전달할 상세 에러 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/comment/exception/CommentException.java b/src/main/java/com/dodo/backend/comment/exception/CommentException.java new file mode 100644 index 0000000..08ddf96 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/exception/CommentException.java @@ -0,0 +1,17 @@ +package com.dodo.backend.comment.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 댓글(Comment) 도메인 비즈니스 로직 수행 중 발생하는 전용 예외 클래스입니다. + */ +@AllArgsConstructor +@Getter +public class CommentException extends RuntimeException { + + /** + * 발생한 예외의 구체적인 종류를 담고 있는 Enum입니다. + */ + private final CommentErrorCode errorCode; +} diff --git a/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..b890dbe --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java @@ -0,0 +1,47 @@ +package com.dodo.backend.comment.mapper; + +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 댓글(Comment) 도메인의 목록 조회 및 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. + */ +@Mapper +public interface CommentMapper { + + /** + * 특정 게시글의 댓글 목록을 페이지 단위로 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 댓글 목록 조회 결과 + */ + List findCommentsByBoardId( + @Param("boardId") Long boardId, + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 특정 게시글의 댓글 전체 개수를 조회합니다. + * + * @param boardId 댓글 수를 조회할 게시글 ID + * @return 댓글 전체 개수 + */ + long countCommentsByBoardId(@Param("boardId") Long boardId); + + /** + * 댓글 내용을 수정합니다. + * + * @param commentId 수정할 댓글 ID + * @param commentContent 수정할 댓글 내용 + */ + void updateComment( + @Param("commentId") Long commentId, + @Param("commentContent") String commentContent + ); +} diff --git a/src/main/java/com/dodo/backend/comment/repository/CommentRepository.java b/src/main/java/com/dodo/backend/comment/repository/CommentRepository.java new file mode 100644 index 0000000..9860f1f --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/repository/CommentRepository.java @@ -0,0 +1,12 @@ +package com.dodo.backend.comment.repository; + +import com.dodo.backend.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * {@link Comment} 엔티티의 데이터베이스 접근을 담당하는 리포지토리 인터페이스입니다. + */ +@Repository +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/com/dodo/backend/comment/service/CommentService.java b/src/main/java/com/dodo/backend/comment/service/CommentService.java new file mode 100644 index 0000000..5f01e58 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/service/CommentService.java @@ -0,0 +1,57 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.exception.CommentException; + +import java.util.UUID; + +/** + * 댓글(Comment) 도메인의 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface CommentService { + + /** + * 댓글을 작성합니다. + * + * @param userId 요청 사용자 ID + * @param request 댓글 작성 요청 DTO + * @return 댓글 작성 응답 DTO + * @throws CommentException 잘못된 요청 또는 부모 댓글이 없는 경우 + */ + CommentCreateResponse createComment(UUID userId, CommentCreateRequest request); + + /** + * 특정 게시글의 댓글 목록을 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 댓글 목록 조회 응답 DTO + */ + CommentListResponse getComments(Long boardId, int page, int size); + + /** + * 특정 댓글을 수정합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 수정할 댓글 ID + * @param request 댓글 수정 요청 DTO + * @return 댓글 수정 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 수정 권한 없음인 경우 + */ + CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentUpdateRequest request); + + /** + * 특정 댓글을 삭제 상태로 변경합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 삭제할 댓글 ID + * @return 댓글 삭제 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 삭제 권한 없음인 경우 + */ + CommentSimpleResponse deleteComment(UUID userId, Long commentId); +} diff --git a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java new file mode 100644 index 0000000..e4bb7b6 --- /dev/null +++ b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java @@ -0,0 +1,236 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.exception.CommentErrorCode; +import com.dodo.backend.comment.exception.CommentException; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static com.dodo.backend.comment.exception.CommentErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.comment.exception.CommentErrorCode.DELETE_PERMISSION_DENIED; +import static com.dodo.backend.comment.exception.CommentErrorCode.INVALID_REQUEST; +import static com.dodo.backend.comment.exception.CommentErrorCode.UPDATE_PERMISSION_DENIED; + +/** + * {@link CommentService} 구현체입니다. + *

+ * 댓글 작성, 목록 조회, 수정, 삭제 비즈니스 로직을 처리합니다. + */ +@Service +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private static final int MAX_COMMENT_LIST_SIZE = 100; + + private final CommentRepository commentRepository; + private final CommentMapper commentMapper; + private final BoardService boardService; + private final UserService userService; + + /** + * 댓글을 작성합니다. + *

+ * 댓글 작성 시 게시글은 BoardService를 통해 조회하고, 작성자는 UserService를 통해 조회합니다. + * 부모 댓글 ID가 전달되면 같은 게시글의 댓글인지 검증한 뒤 대댓글로 저장합니다. + * + * @param userId 요청 사용자 ID + * @param request 댓글 작성 요청 DTO + * @return 댓글 작성 응답 DTO + * @throws CommentException 잘못된 요청 또는 부모 댓글이 없는 경우 + */ + @Override + @Transactional + public CommentCreateResponse createComment(UUID userId, CommentCreateRequest request) { + if (userId == null || request == null || isBlank(request.getCommentContent())) { + throw new CommentException(INVALID_REQUEST); + } + + Board board = boardService.getBoardById(request.getBoardId()); + validateCommentableBoard(board); + + User user = userService.getUserById(userId); + Comment parentComment = findParentComment(request.getParentCommentId(), board.getBoardId()); + + Comment comment = request.toEntity(board, user, parentComment); + Comment savedComment = commentRepository.save(comment); + + return CommentCreateResponse.toDto(savedComment, "댓글이 성공적으로 작성되었습니다."); + } + + /** + * 특정 게시글의 댓글 목록을 조회합니다. + * + * @param boardId 댓글을 조회할 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 댓글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public CommentListResponse getComments(Long boardId, int page, int size) { + validateCommentListRequest(boardId, page, size); + + Board board = boardService.getBoardById(boardId); + validateCommentableBoard(board); + + int offset = page * size; + List queryResponses = commentMapper.findCommentsByBoardId(boardId, offset, size); + long totalElements = commentMapper.countCommentsByBoardId(boardId); + + return CommentListResponse.toDto( + queryResponses, + page, + size, + totalElements, + "댓글 목록을 성공적으로 조회했습니다." + ); + } + + /** + * 특정 댓글을 수정합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 수정할 댓글 ID + * @param request 댓글 수정 요청 DTO + * @return 댓글 수정 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 수정 권한 없음인 경우 + */ + @Override + @Transactional + public CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentUpdateRequest request) { + if (userId == null || request == null || isBlank(request.getCommentContent())) { + throw new CommentException(INVALID_REQUEST); + } + + Comment comment = findCommentById(commentId); + validateCommentOwner(userId, comment, UPDATE_PERMISSION_DENIED); + + commentMapper.updateComment(commentId, request.getCommentContent()); + + return CommentSimpleResponse.toDto("댓글이 성공적으로 수정되었습니다."); + } + + /** + * 특정 댓글을 삭제합니다. + *

+ * 댓글 삭제는 기본 삭제 작업이므로 JPA Repository를 사용합니다. + * + * @param userId 요청 사용자 ID + * @param commentId 삭제할 댓글 ID + * @return 댓글 삭제 응답 DTO + * @throws CommentException 잘못된 요청, 댓글 없음, 삭제 권한 없음인 경우 + */ + @Override + @Transactional + public CommentSimpleResponse deleteComment(UUID userId, Long commentId) { + if (userId == null) { + throw new CommentException(INVALID_REQUEST); + } + + Comment comment = findCommentById(commentId); + validateCommentOwner(userId, comment, DELETE_PERMISSION_DENIED); + + commentRepository.delete(comment); + + return CommentSimpleResponse.toDto("댓글이 성공적으로 삭제되었습니다."); + } + + /** + * 댓글 ID로 댓글을 조회하고 ID 유효성을 검증합니다. + * + * @param commentId 조회할 댓글 ID + * @return 댓글 엔티티 + */ + private Comment findCommentById(Long commentId) { + if (commentId == null || commentId <= 0) { + throw new CommentException(INVALID_REQUEST); + } + + return commentRepository.findById(commentId) + .orElseThrow(() -> new CommentException(COMMENT_NOT_FOUND)); + } + + /** + * 부모 댓글을 조회하고 같은 게시글의 댓글인지 검증합니다. + * + * @param parentCommentId 부모 댓글 ID + * @param boardId 게시글 ID + * @return 부모 댓글 또는 null + */ + private Comment findParentComment(Long parentCommentId, Long boardId) { + if (parentCommentId == null) { + return null; + } + + Comment parentComment = findCommentById(parentCommentId); + if (parentComment.getBoard() == null || !boardId.equals(parentComment.getBoard().getBoardId())) { + throw new CommentException(INVALID_REQUEST); + } + + return parentComment; + } + + /** + * 댓글 작성 또는 조회 가능한 게시글인지 검증합니다. + * + * @param board 검증할 게시글 + */ + private void validateCommentableBoard(Board board) { + if (board == null || board.getBoardStatus() != BoardStatus.PUBLISHED) { + throw new CommentException(INVALID_REQUEST); + } + } + + /** + * 댓글 목록 조회 요청 값을 검증합니다. + * + * @param boardId 게시글 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + */ + private void validateCommentListRequest(Long boardId, int page, int size) { + if (boardId == null || boardId <= 0 || page < 0 || size <= 0 || size > MAX_COMMENT_LIST_SIZE || page > Integer.MAX_VALUE / size) { + throw new CommentException(INVALID_REQUEST); + } + } + + /** + * 댓글 작성자인지 검증합니다. + * + * @param userId 요청 사용자 ID + * @param comment 검증할 댓글 + * @param errorCode 권한이 없을 때 발생시킬 에러 코드 + */ + private void validateCommentOwner(UUID userId, Comment comment, CommentErrorCode errorCode) { + if (comment.getUser() == null || !userId.equals(comment.getUser().getUsersId())) { + throw new CommentException(errorCode); + } + } + + /** + * 문자열이 null이거나 공백인지 확인합니다. + * + * @param value 확인할 문자열 + * @return null 또는 공백이면 true + */ + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java b/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java index 59a3279..aa0e7c9 100644 --- a/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java +++ b/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java @@ -16,7 +16,7 @@ public class TestWebSocketController { /** * 소켓 테스트 페이지 진입 *

- * 접속 URL: http://localhost:8080/view/socket + * 접속 URL: https://api.dodok.p-e.kr/view/socket * 뷰 경로: resources/templates/websocket/socket-test.html */ @GetMapping("/view/socket") @@ -24,4 +24,4 @@ public String socketTestPage() { log.info("웹소켓 시뮬레이션 페이지가 요청되었습니다."); return "websocket/socket-test"; } -} \ No newline at end of file +} diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index c5343f6..bfce201 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -40,4 +40,67 @@ board_status_updated_at = NOW() WHERE board_id = #{boardId} + + + + + + + diff --git a/src/main/resources/templates/websocket/socket-test.html b/src/main/resources/templates/websocket/socket-test.html index 3a18d30..16c3450 100644 --- a/src/main/resources/templates/websocket/socket-test.html +++ b/src/main/resources/templates/websocket/socket-test.html @@ -3,129 +3,139 @@ - DoDo 웹소켓 시뮬레이터 + DoDo 웹소켓 테스트 - -

-

DoDo 웹소켓 시뮬레이터

+

DoDo 웹소켓 테스트

연결 해제됨
-
인증 및 연결 설정
+
인증 및 연결
- - + +
-
- - +
+ +
-
- - +
+ +
-
활동 데이터 시뮬레이션
-
+
활동 경로
+
- +
- +
- +
-
- - +
+ + +
-
-
울타리 위치 데이터 시뮬레이션
-
+
울타리 위치
+
- +
- +
-
- - +
+ + +
-
-
-
실시간 로그 터미널
+
+
실시간 로그
diff --git a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java index b161282..5e4de88 100644 --- a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java +++ b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java @@ -13,6 +13,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListItemResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; import java.time.LocalDateTime; import java.util.List; @@ -33,6 +38,60 @@ class BoardControllerTest { @Mock private BoardService boardService; + /** + * 게시글 목록 조회 요청 시 200 상태코드와 게시글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 목록 조회 성공: 200 상태코드와 게시글 목록을 반환한다.") + void getBoardList_Success() { + log.info("테스트 시작: 게시글 목록 조회 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + + BoardListItemResponse itemResponse = BoardListItemResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContentPreview("오늘 산책하다가 찍은 사진이에요") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(0L) + .dislikeCount(0L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + BoardListResponse serviceResponse = BoardListResponse.builder() + .message("게시글 목록 조회를 성공했습니다.") + .boards(List.of(itemResponse)) + .totalPages(1) + .totalElements(1L) + .currentPage(0) + .pageSize(10) + .build(); + + given(boardService.getBoardList(0, 10)).willReturn(serviceResponse); + + // when + ResponseEntity response = boardController.getBoardList(0, 10, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글 목록 조회를 성공했습니다.", response.getBody().getMessage()); + assertEquals(1, response.getBody().getBoards().size()); + assertEquals(1L, response.getBody().getBoards().get(0).getBoardId()); + + verify(boardService).getBoardList(0, 10); + + log.info("테스트 종료: 게시글 목록 조회 성공 시나리오"); + } + /** * 게시글 작성 요청 시 200 상태코드와 생성된 게시글 ID를 반환하는지 검증합니다. */ @@ -78,6 +137,85 @@ void createBoard_Success() { log.info("테스트 종료: 게시글 작성 성공 시나리오"); } + /** + * 게시글 임시 저장 요청 시 200 상태코드와 세션 키를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 임시 저장 성공: 200 상태코드와 세션 키를 반환한다.") + void tempSaveBoard_Success() { + log.info("테스트 시작: 게시글 임시 저장 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + + BoardTempSaveRequest request = BoardTempSaveRequest.builder() + .boardTitle("임시 저장 제목") + .boardContent("임시 저장 내용") + .imageFileUrls(List.of("https://example.com/images/bori.jpg")) + .build(); + + BoardTempSaveResponse serviceResponse = BoardTempSaveResponse.toDto( + "session-key", + "게시글이 성공적으로 임시 저장되었습니다." + ); + + given(boardService.tempSaveBoard(userId, request)).willReturn(serviceResponse); + + // when + ResponseEntity response = boardController.tempSaveBoard(request, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글이 성공적으로 임시 저장되었습니다.", response.getBody().getMessage()); + assertEquals("session-key", response.getBody().getSessionKey()); + + verify(boardService).tempSaveBoard(userId, request); + + log.info("테스트 종료: 게시글 임시 저장 성공 시나리오"); + } + + /** + * 임시 저장 게시글 조회 요청 시 200 상태코드와 저장된 데이터를 반환하는지 검증합니다. + */ + @Test + @DisplayName("임시 저장 게시글 조회 성공: 200 상태코드와 저장된 데이터를 반환한다.") + void getTempSavedBoard_Success() { + log.info("테스트 시작: 임시 저장 게시글 조회 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + String sessionKey = "session-key"; + UserDetails userDetails = createUserDetails(userId); + + BoardTempSaveDetailResponse serviceResponse = BoardTempSaveDetailResponse.builder() + .message("임시 저장된 게시글을 성공적으로 불러왔습니다.") + .boardTitle("임시 저장 제목") + .boardContent("임시 저장 내용") + .imageFileUrls(List.of("https://example.com/images/bori.jpg")) + .build(); + + given(boardService.getTempSavedBoard(userId, sessionKey)).willReturn(serviceResponse); + + // when + ResponseEntity response = boardController.getTempSavedBoard(sessionKey, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("임시 저장된 게시글을 성공적으로 불러왔습니다.", response.getBody().getMessage()); + assertEquals("임시 저장 제목", response.getBody().getBoardTitle()); + + verify(boardService).getTempSavedBoard(userId, sessionKey); + + log.info("테스트 종료: 임시 저장 게시글 조회 성공 시나리오"); + } + /** * 게시글 상세 조회 요청 시 200 상태코드와 게시글 상세 정보를 반환하는지 검증합니다. */ @@ -212,4 +350,17 @@ void deleteBoard_Success() { verify(boardService).deleteBoard(userId, boardId); log.info("테스트 종료: 게시글 삭제 성공 시나리오"); } + + /** + * 테스트용 인증 사용자 정보를 생성합니다. + * + * @param userId 사용자 ID + * @return 인증 사용자 정보 + */ + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } } diff --git a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java index 7789d06..6a9cf51 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -1,9 +1,14 @@ package com.dodo.backend.board.service; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.request.BoardRequest.BoardTempSaveRequest; import com.dodo.backend.board.dto.request.BoardRequest.BoardUpdateRequest; import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; import com.dodo.backend.board.dto.response.BoardResponse.BoardSimpleResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveDetailResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardTempSaveResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; @@ -22,6 +27,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import java.time.LocalDateTime; import java.util.List; @@ -36,6 +43,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; /** * {@link BoardService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. @@ -59,6 +71,77 @@ class BoardServiceTest { @Mock private BoardMapper boardMapper; + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + /** + * 게시글 목록 조회 시 게시글 목록과 페이지 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 목록 조회 성공: 게시글 목록과 페이지 정보를 반환한다.") + void getBoardList_Success() { + log.info("테스트 시작: 게시글 목록 조회 성공"); + + // given + BoardListQueryResponse queryResponse = BoardListQueryResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContent("오늘 산책하다가 찍은 사진이에요. 너무 귀엽죠?") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(0L) + .dislikeCount(0L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + given(boardMapper.findBoardList(0, 10)).willReturn(List.of(queryResponse)); + given(boardMapper.countPublishedBoards()).willReturn(1L); + + // when + BoardListResponse response = boardService.getBoardList(0, 10); + + // then + assertNotNull(response); + assertEquals("게시글 목록 조회를 성공했습니다.", response.getMessage()); + assertEquals(1, response.getBoards().size()); + assertEquals(1, response.getTotalPages()); + assertEquals(1L, response.getTotalElements()); + assertEquals(0, response.getCurrentPage()); + assertEquals(10, response.getPageSize()); + assertEquals("우리 강아지 자랑합니다", response.getBoards().get(0).getBoardTitle()); + assertEquals("오늘 산책하다가 찍은 사진이에요. 너", response.getBoards().get(0).getBoardContentPreview()); + + verify(boardMapper).findBoardList(0, 10); + verify(boardMapper).countPublishedBoards(); + + log.info("테스트 종료: 게시글 목록 조회 성공"); + } + + /** + * 게시글 목록 조회 시 잘못된 페이지 크기이면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 목록 조회 실패: 페이지 크기가 100보다 크면 예외가 발생한다.") + void getBoardList_Fail_InvalidSize() { + log.info("테스트 시작: 게시글 목록 조회 실패 - 잘못된 페이지 크기"); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getBoardList(0, 101)); + + // then + assertEquals(BoardErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(boardMapper, never()).findBoardList(0, 101); + + log.info("테스트 종료: 게시글 목록 조회 실패 - 잘못된 페이지 크기"); + } + /** * 게시글 작성 요청 시 작성자를 조회하고 게시글을 저장한 뒤 게시글 ID를 반환하는지 검증합니다. */ @@ -237,7 +320,7 @@ void getBoardDetail_Fail_InvalidBoardId() { */ @Test @DisplayName("게시글 상세 조회 실패: 게시글을 찾을 수 없으면 예외가 발생한다.") - void getBoardDetail_Fail_BoardNotFound() { + void getBoardDetail_Fail_BoardNotFound_Duplicate() { log.info("테스트 시작: 게시글 상세 조회 실패 - 게시글 없음"); // given @@ -286,6 +369,116 @@ void getBoardDetail_Fail_ViewPermissionDenied() { verify(boardRepository).findById(boardId); log.info("테스트 종료: 게시글 상세 조회 실패 - 조회 권한 없음 검증 완료"); } + /** + * 조회할 게시글이 존재하지 않으면 게시글 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 상세 조회 실패: 게시글을 찾을 수 없으면 예외가 발생한다.") + void getBoardDetail_Fail_BoardNotFound() { + log.info("테스트 시작: 게시글 상세 조회 실패 - 게시글 없음"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 999L; + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getBoardDetail(userId, boardId)); + + // then + assertEquals(BoardErrorCode.BOARD_NOT_FOUND, exception.getErrorCode()); + verify(boardRepository).findById(boardId); + log.info("테스트 종료: 게시글 상세 조회 실패 - 게시글 없음 검증 완료"); + } + + + /** + * 작성자 본인이 게시글 제목, 내용, 이미지를 수정하면 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 수정 성공: 작성자 본인이 게시글을 수정한다.") + void updateBoard_Success_Duplicate() { + log.info("테스트 시작: 게시글 수정 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + + Board board = Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("기존 제목") + .boardContent("기존 내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + BoardUpdateRequest request = BoardUpdateRequest.builder() + .boardTitle("우와 우리 애가!") + .boardContent("산책을 했어요!!") + .imageFileUrls(List.of( + "https://example.com/images/bori_1.jpg", + "https://example.com/images/bori_2.jpg" + )) + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardSimpleResponse response = boardService.updateBoard(userId, boardId, request); + + // then + assertEquals("게시글이 성공적으로 수정되었습니다.", response.getMessage()); + verify(boardRepository).findById(boardId); + verify(boardMapper).updateBoard(boardId, request); + verify(imageFileService).replaceBoardImages(board, request.getImageFileUrls()); + + log.info("테스트 종료: 게시글 수정 성공 검증 완료"); + } + + /** + * 작성자가 아닌 사용자가 게시글을 수정하면 수정 권한 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 수정 실패: 작성자가 아니면 예외가 발생한다.") + void updateBoard_Fail_PermissionDenied_Duplicate() { + log.info("테스트 시작: 게시글 수정 실패 - 권한 없음"); + + // given + UUID ownerId = UUID.randomUUID(); + UUID requestUserId = UUID.randomUUID(); + Long boardId = 1L; + User owner = mock(User.class); + given(owner.getUsersId()).willReturn(ownerId); + + Board board = Board.builder() + .boardId(boardId) + .user(owner) + .boardTitle("기존 제목") + .boardContent("기존 내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + BoardUpdateRequest request = BoardUpdateRequest.builder() + .boardTitle("수정 제목") + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.updateBoard(requestUserId, boardId, request)); + + // then + assertEquals(BoardErrorCode.UPDATE_PERMISSION_DENIED, exception.getErrorCode()); + verify(boardRepository).findById(boardId); + + log.info("테스트 종료: 게시글 수정 실패 - 권한 없음 검증 완료"); + } /** * 작성자 본인이 게시글 제목, 내용, 이미지를 수정하면 성공 메시지를 반환하는지 검증합니다. diff --git a/src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java b/src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..8c0f9b4 --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/controller/CommentControllerTest.java @@ -0,0 +1,210 @@ +package com.dodo.backend.comment.controller; + +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentAuthorResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentItemResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.PageInfoResponse; +import com.dodo.backend.comment.service.CommentService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * {@link CommentController}의 HTTP 요청 처리 로직을 검증하는 테스트 클래스입니다. + */ +@Slf4j +@ExtendWith(MockitoExtension.class) +class CommentControllerTest { + + @Mock + private CommentService commentService; + + /** + * 댓글 작성 요청 시 200 상태코드와 작성된 댓글 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 작성 성공: 200 상태코드와 작성된 댓글 정보를 반환한다.") + void createComment_Success() { + log.info("테스트 시작: 댓글 작성 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(1L) + .commentContent("좋은 정보 감사합니다!") + .parentCommentId(null) + .build(); + + CommentCreateResponse serviceResponse = CommentCreateResponse.builder() + .message("댓글이 성공적으로 작성되었습니다.") + .commentId(123L) + .commentContent("좋은 정보 감사합니다!") + .userId(userId.toString()) + .nickname("멍멍이집사") + .build(); + + given(commentService.createComment(userId, request)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.createComment(request, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글이 성공적으로 작성되었습니다.", response.getBody().getMessage()); + assertEquals(123L, response.getBody().getCommentId()); + assertEquals("멍멍이집사", response.getBody().getNickname()); + + verify(commentService).createComment(userId, request); + + log.info("테스트 종료: 댓글 작성 성공 시나리오"); + } + + /** + * 댓글 목록 조회 요청 시 200 상태코드와 댓글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 목록 조회 성공: 200 상태코드와 댓글 목록을 반환한다.") + void getComments_Success() { + log.info("테스트 시작: 댓글 목록 조회 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + UserDetails userDetails = createUserDetails(userId); + + CommentItemResponse itemResponse = CommentItemResponse.builder() + .commentId(102L) + .parentCommentId(null) + .commentContent("두 번째 댓글입니다.") + .author(CommentAuthorResponse.toDto("uuid-user-2", "산책왕뽀삐")) + .createdAt(LocalDateTime.of(2025, 10, 14, 14, 30)) + .build(); + + CommentListResponse serviceResponse = CommentListResponse.builder() + .message("댓글 목록을 성공적으로 조회했습니다.") + .pageInfo(PageInfoResponse.toDto(0, 10, 1L)) + .data(List.of(itemResponse)) + .build(); + + given(commentService.getComments(boardId, 0, 10)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.getComments(boardId, 0, 10, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글 목록을 성공적으로 조회했습니다.", response.getBody().getMessage()); + assertEquals(1, response.getBody().getData().size()); + assertEquals(102L, response.getBody().getData().get(0).getCommentId()); + + verify(commentService).getComments(boardId, 0, 10); + + log.info("테스트 종료: 댓글 목록 조회 성공 시나리오"); + } + + /** + * 댓글 수정 요청 시 200 상태코드와 수정 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 수정 성공: 200 상태코드와 수정 성공 메시지를 반환한다.") + void updateComment_Success() { + log.info("테스트 시작: 댓글 수정 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + UserDetails userDetails = createUserDetails(userId); + + CommentUpdateRequest request = CommentUpdateRequest.builder() + .commentContent("수정입니다.") + .build(); + + CommentSimpleResponse serviceResponse = CommentSimpleResponse.toDto("댓글이 성공적으로 수정되었습니다."); + given(commentService.updateComment(userId, commentId, request)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.updateComment(commentId, request, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글이 성공적으로 수정되었습니다.", response.getBody().getMessage()); + + verify(commentService).updateComment(userId, commentId, request); + + log.info("테스트 종료: 댓글 수정 성공 시나리오"); + } + + /** + * 댓글 삭제 요청 시 200 상태코드와 삭제 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 삭제 성공: 200 상태코드와 삭제 성공 메시지를 반환한다.") + void deleteComment_Success() { + log.info("테스트 시작: 댓글 삭제 성공 시나리오"); + + // given + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + UserDetails userDetails = createUserDetails(userId); + + CommentSimpleResponse serviceResponse = CommentSimpleResponse.toDto("댓글이 성공적으로 삭제되었습니다."); + given(commentService.deleteComment(userId, commentId)).willReturn(serviceResponse); + + // when + ResponseEntity response = commentController.deleteComment(commentId, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("댓글이 성공적으로 삭제되었습니다.", response.getBody().getMessage()); + + verify(commentService).deleteComment(userId, commentId); + + log.info("테스트 종료: 댓글 삭제 성공 시나리오"); + } + + /** + * 테스트용 인증 사용자 정보를 생성합니다. + * + * @param userId 사용자 ID + * @return 인증 사용자 정보 + */ + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java b/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..12a7474 --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java @@ -0,0 +1,385 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentUpdateRequest; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.exception.CommentErrorCode; +import com.dodo.backend.comment.exception.CommentException; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * {@link CommentService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. + */ +@Slf4j +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentMapper commentMapper; + + @Mock + private BoardService boardService; + + @Mock + private UserService userService; + + /** + * 댓글 작성 요청 시 게시글과 사용자를 조회하고 댓글을 저장하는지 검증합니다. + */ + @Test + @DisplayName("댓글 작성 성공: 댓글이 정상적으로 저장되고 작성 응답을 반환한다.") + void createComment_Success() { + log.info("테스트 시작: 댓글 작성 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + given(user.getNickname()).willReturn("멍멍이집사"); + + Board board = Board.builder() + .boardId(boardId) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(boardId) + .commentContent("좋은 정보 감사합니다!") + .parentCommentId(null) + .build(); + + Comment savedComment = Comment.builder() + .commentId(123L) + .board(board) + .user(user) + .commentContent(request.getCommentContent()) + .build(); + + given(boardService.getBoardById(boardId)).willReturn(board); + given(userService.getUserById(userId)).willReturn(user); + given(commentRepository.save(any(Comment.class))).willReturn(savedComment); + + // when + CommentCreateResponse response = commentService.createComment(userId, request); + + // then + assertNotNull(response); + assertEquals("댓글이 성공적으로 작성되었습니다.", response.getMessage()); + assertEquals(123L, response.getCommentId()); + assertEquals("좋은 정보 감사합니다!", response.getCommentContent()); + assertEquals(userId.toString(), response.getUserId()); + assertEquals("멍멍이집사", response.getNickname()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(commentRepository).save(captor.capture()); + assertEquals(board, captor.getValue().getBoard()); + assertEquals(user, captor.getValue().getUser()); + assertNull(captor.getValue().getParentComment()); + assertEquals("좋은 정보 감사합니다!", captor.getValue().getCommentContent()); + + log.info("테스트 종료: 댓글 작성 성공"); + } + + /** + * 부모 댓글 ID가 전달되면 같은 게시글의 댓글을 부모 댓글로 연결하는지 검증합니다. + */ + @Test + @DisplayName("대댓글 작성 성공: 부모 댓글이 같은 게시글에 있으면 대댓글로 저장한다.") + void createComment_Success_WithParentComment() { + log.info("테스트 시작: 대댓글 작성 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long boardId = 1L; + Long parentCommentId = 10L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + given(user.getNickname()).willReturn("멍멍이집사"); + + Board board = Board.builder() + .boardId(boardId) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + Comment parentComment = Comment.builder() + .commentId(parentCommentId) + .board(board) + .user(user) + .commentContent("부모 댓글") + .build(); + + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(boardId) + .commentContent("대댓글입니다.") + .parentCommentId(parentCommentId) + .build(); + + Comment savedComment = Comment.builder() + .commentId(123L) + .board(board) + .user(user) + .parentComment(parentComment) + .commentContent(request.getCommentContent()) + .build(); + + given(boardService.getBoardById(boardId)).willReturn(board); + given(userService.getUserById(userId)).willReturn(user); + given(commentRepository.findById(parentCommentId)).willReturn(Optional.of(parentComment)); + given(commentRepository.save(any(Comment.class))).willReturn(savedComment); + + // when + CommentCreateResponse response = commentService.createComment(userId, request); + + // then + assertNotNull(response); + assertEquals(123L, response.getCommentId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); + verify(commentRepository).save(captor.capture()); + assertEquals(parentComment, captor.getValue().getParentComment()); + + log.info("테스트 종료: 대댓글 작성 성공"); + } + + /** + * 댓글 목록 조회 시 댓글 목록과 페이지 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 목록 조회 성공: 댓글 목록과 페이지 정보를 반환한다.") + void getComments_Success() { + log.info("테스트 시작: 댓글 목록 조회 성공"); + + // given + Long boardId = 1L; + Board board = Board.builder() + .boardId(boardId) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + CommentListQueryResponse queryResponse = CommentListQueryResponse.builder() + .commentId(102L) + .parentCommentId(null) + .commentContent("두 번째 댓글입니다.") + .userId("uuid-user-2") + .nickname("산책왕뽀삐") + .createdAt(LocalDateTime.of(2025, 10, 14, 14, 30)) + .build(); + + given(boardService.getBoardById(boardId)).willReturn(board); + given(commentMapper.findCommentsByBoardId(boardId, 0, 10)).willReturn(List.of(queryResponse)); + given(commentMapper.countCommentsByBoardId(boardId)).willReturn(1L); + + // when + CommentListResponse response = commentService.getComments(boardId, 0, 10); + + // then + assertNotNull(response); + assertEquals("댓글 목록을 성공적으로 조회했습니다.", response.getMessage()); + assertEquals(1, response.getData().size()); + assertEquals(102L, response.getData().get(0).getCommentId()); + assertEquals(1L, response.getPageInfo().getTotalElements()); + assertEquals(1, response.getPageInfo().getTotalPages()); + + verify(boardService).getBoardById(boardId); + verify(commentMapper).findCommentsByBoardId(boardId, 0, 10); + verify(commentMapper).countCommentsByBoardId(boardId); + + log.info("테스트 종료: 댓글 목록 조회 성공"); + } + + /** + * 댓글 목록 조회 시 잘못된 페이지 크기이면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("댓글 목록 조회 실패: 페이지 크기가 100보다 크면 예외가 발생한다.") + void getComments_Fail_InvalidSize() { + log.info("테스트 시작: 댓글 목록 조회 실패 - 잘못된 페이지 크기"); + + // when + CommentException exception = assertThrows(CommentException.class, + () -> commentService.getComments(1L, 0, 101)); + + // then + assertEquals(CommentErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(commentMapper, never()).findCommentsByBoardId(1L, 0, 101); + + log.info("테스트 종료: 댓글 목록 조회 실패 - 잘못된 페이지 크기"); + } + + /** + * 댓글 작성자 본인이 댓글을 수정하면 Mapper를 통해 댓글 내용이 수정되는지 검증합니다. + */ + @Test + @DisplayName("댓글 수정 성공: 작성자 본인이 댓글을 수정한다.") + void updateComment_Success() { + log.info("테스트 시작: 댓글 수정 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + + Comment comment = Comment.builder() + .commentId(commentId) + .user(user) + .commentContent("기존 댓글") + .build(); + + CommentUpdateRequest request = CommentUpdateRequest.builder() + .commentContent("수정입니다.") + .build(); + + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + CommentSimpleResponse response = commentService.updateComment(userId, commentId, request); + + // then + assertEquals("댓글이 성공적으로 수정되었습니다.", response.getMessage()); + verify(commentRepository).findById(commentId); + verify(commentMapper).updateComment(commentId, "수정입니다."); + + log.info("테스트 종료: 댓글 수정 성공"); + } + + /** + * 댓글 작성자가 아닌 사용자가 댓글을 수정하면 권한 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("댓글 수정 실패: 작성자가 아니면 예외가 발생한다.") + void updateComment_Fail_PermissionDenied() { + log.info("테스트 시작: 댓글 수정 실패 - 권한 없음"); + + // given + UUID ownerId = UUID.randomUUID(); + UUID requestUserId = UUID.randomUUID(); + Long commentId = 123L; + User owner = mock(User.class); + given(owner.getUsersId()).willReturn(ownerId); + + Comment comment = Comment.builder() + .commentId(commentId) + .user(owner) + .commentContent("기존 댓글") + .build(); + + CommentUpdateRequest request = CommentUpdateRequest.builder() + .commentContent("수정입니다.") + .build(); + + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + CommentException exception = assertThrows(CommentException.class, + () -> commentService.updateComment(requestUserId, commentId, request)); + + // then + assertEquals(CommentErrorCode.UPDATE_PERMISSION_DENIED, exception.getErrorCode()); + verify(commentRepository).findById(commentId); + verify(commentMapper, never()).updateComment(commentId, "수정입니다."); + + log.info("테스트 종료: 댓글 수정 실패 - 권한 없음"); + } + + /** + * 댓글 작성자 본인이 댓글을 삭제하면 Repository를 통해 댓글이 삭제되는지 검증합니다. + */ + @Test + @DisplayName("댓글 삭제 성공: 작성자 본인이 댓글을 삭제한다.") + void deleteComment_Success() { + log.info("테스트 시작: 댓글 삭제 성공"); + + // given + UUID userId = UUID.randomUUID(); + Long commentId = 123L; + User user = mock(User.class); + given(user.getUsersId()).willReturn(userId); + + Comment comment = Comment.builder() + .commentId(commentId) + .user(user) + .commentContent("삭제할 댓글") + .build(); + + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + CommentSimpleResponse response = commentService.deleteComment(userId, commentId); + + // then + assertEquals("댓글이 성공적으로 삭제되었습니다.", response.getMessage()); + verify(commentRepository).findById(commentId); + verify(commentRepository).delete(comment); + + log.info("테스트 종료: 댓글 삭제 성공"); + } + + /** + * 삭제할 댓글이 존재하지 않으면 댓글 없음 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("댓글 삭제 실패: 댓글을 찾을 수 없으면 예외가 발생한다.") + void deleteComment_Fail_CommentNotFound() { + log.info("테스트 시작: 댓글 삭제 실패 - 댓글 없음"); + + // given + UUID userId = UUID.randomUUID(); + Long commentId = 999L; + given(commentRepository.findById(commentId)).willReturn(Optional.empty()); + + // when + CommentException exception = assertThrows(CommentException.class, + () -> commentService.deleteComment(userId, commentId)); + + // then + assertEquals(CommentErrorCode.COMMENT_NOT_FOUND, exception.getErrorCode()); + verify(commentRepository).findById(commentId); + + log.info("테스트 종료: 댓글 삭제 실패 - 댓글 없음"); + } +} From f1e37f7353f92189a47238b9b0f35b33abd31f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=ED=98=84=EB=B9=88?= <63556578+limhb708@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:30:55 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dodo/backend/board/mapper/BoardMapper.xml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index c5343f6..bfce201 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -40,4 +40,67 @@ board_status_updated_at = NOW() WHERE board_id = #{boardId} + + + + + + + From 9b9d5f69c7b0b0e101a863954029fc35921cded1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=ED=98=84=EB=B9=88?= <63556578+limhb708@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:30:55 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TestWebSocketController.java | 4 +- .../templates/websocket/socket-test.html | 446 ++++++++++-------- 2 files changed, 247 insertions(+), 203 deletions(-) diff --git a/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java b/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java index 59a3279..aa0e7c9 100644 --- a/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java +++ b/src/main/java/com/dodo/backend/routepoint/controller/TestWebSocketController.java @@ -16,7 +16,7 @@ public class TestWebSocketController { /** * 소켓 테스트 페이지 진입 *

- * 접속 URL: http://localhost:8080/view/socket + * 접속 URL: https://api.dodok.p-e.kr/view/socket * 뷰 경로: resources/templates/websocket/socket-test.html */ @GetMapping("/view/socket") @@ -24,4 +24,4 @@ public String socketTestPage() { log.info("웹소켓 시뮬레이션 페이지가 요청되었습니다."); return "websocket/socket-test"; } -} \ No newline at end of file +} diff --git a/src/main/resources/templates/websocket/socket-test.html b/src/main/resources/templates/websocket/socket-test.html index 3a18d30..16c3450 100644 --- a/src/main/resources/templates/websocket/socket-test.html +++ b/src/main/resources/templates/websocket/socket-test.html @@ -3,129 +3,139 @@ - DoDo 웹소켓 시뮬레이터 + DoDo 웹소켓 테스트 - -

-

DoDo 웹소켓 시뮬레이터

+

DoDo 웹소켓 테스트

연결 해제됨
-
인증 및 연결 설정
+
인증 및 연결
- - + +
-
- - +
+ +
-
- - +
+ +
-
활동 데이터 시뮬레이션
-
+
활동 경로
+
- +
- +
- +
-
- - +
+ + +
-
-
울타리 위치 데이터 시뮬레이션
-
+
울타리 위치
+
- +
- +
-
- - +
+ + +
-
-
-
실시간 로그 터미널
+
+
실시간 로그
From 623534e599e233b2b99dc3e08428c2b2f3f36640 Mon Sep 17 00:00:00 2001 From: limhb708 Date: Fri, 12 Jun 2026 20:47:21 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=93=B4=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=B0=8F=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 내가 쓴 게시글 목록 조회 API 추가 - 내가 쓴 댓글 목록 조회 API 추가 - Board/Comment Mapper 조회 쿼리 및 페이징 처리 구현 - 댓글 응답 DTO 및 MyBatis 매퍼 XML 추가 - Service/Controller 테스트 코드 작성 --- .../board/controller/BoardController.java | 31 ++++ .../backend/board/mapper/BoardMapper.java | 23 +++ .../backend/board/service/BoardService.java | 10 ++ .../board/service/BoardServiceImpl.java | 29 ++++ .../comment/controller/CommentController.java | 32 ++++ .../comment/dto/response/CommentResponse.java | 137 ++++++++++++++++++ .../backend/comment/mapper/CommentMapper.java | 24 +++ .../comment/service/CommentService.java | 11 ++ .../comment/service/CommentServiceImpl.java | 43 ++++++ .../dodo/backend/board/mapper/BoardMapper.xml | 109 ++++++++++++-- .../backend/comment/mapper/CommentMapper.xml | 61 ++++++++ .../BoardMyBoardControllerTest.java | 88 +++++++++++ .../service/BoardMyBoardServiceTest.java | 103 +++++++++++++ .../CommentMyCommentControllerTest.java | 82 +++++++++++ .../service/CommentMyCommentServiceTest.java | 94 ++++++++++++ 15 files changed, 864 insertions(+), 13 deletions(-) create mode 100644 src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml create mode 100644 src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java create mode 100644 src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java create mode 100644 src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java create mode 100644 src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index f2c4de7..73126a3 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -74,6 +74,37 @@ public ResponseEntity getBoardList( return ResponseEntity.ok(boardService.getBoardList(page, size)); } + + /** + * 요청 사용자가 작성한 게시글 목록을 조회합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 내가 쓴 게시글 목록 조회 응답 + */ + @Operation(summary = "내가 쓴 게시글 목록 조회", description = "인증된 사용자가 작성한 게시글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "내가 쓴 게시글 목록을 성공적으로 조회했습니다.", + content = @Content(schema = @Schema(implementation = BoardListResponse.class))), + @ApiResponse(responseCode = "400", description = "?섎せ???붿껌?낅땲??", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "濡쒓렇?몄씠 ?꾩슂??湲곕뒫?낅땲??", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "?쒕쾭 ?대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me") + public ResponseEntity getMyBoards( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("내가 쓴 게시글 목록 조회 요청 수신 - User: {}, Page: {}, Size: {}", userId, page, size); + + return ResponseEntity.ok(boardService.getMyBoards(userId, page, size)); + } /** * 새 게시글을 작성합니다. * diff --git a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java index a97ac8d..a0c8606 100644 --- a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java +++ b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java @@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +import java.util.UUID; /** * 게시글(Board) 도메인의 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. @@ -32,6 +33,28 @@ List findBoardList( */ long countPublishedBoards(); + /** + * 특정 사용자가 작성한 게시글 목록을 페이지 단위로 조회합니다. + * + * @param userId 조회할 사용자 ID + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 내가 쓴 게시글 목록 조회 결과 + */ + List findMyBoardList( + @Param("userId") UUID userId, + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 특정 사용자가 작성한 게시글 전체 개수를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 내가 쓴 게시글 전체 개수 + */ + long countMyBoards(@Param("userId") UUID userId); + /** * 게시글 제목과 내용을 선택적으로 수정합니다. * diff --git a/src/main/java/com/dodo/backend/board/service/BoardService.java b/src/main/java/com/dodo/backend/board/service/BoardService.java index 9056f15..8eae232 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -36,6 +36,16 @@ public interface BoardService { */ BoardListResponse getBoardList(int page, int size); + /** + * 요청 사용자가 작성한 게시글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 게시글 목록 조회 응답 DTO + */ + BoardListResponse getMyBoards(UUID userId, int page, int size); + /** * 새 게시글을 생성하고 이미지 URL 목록을 게시글에 연결합니다. * diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index 73cc6cf..6e2339f 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -124,6 +124,35 @@ public BoardListResponse getBoardList(int page, int size) { ); } + /** + * 요청 사용자가 작성한 게시글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 게시글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public BoardListResponse getMyBoards(UUID userId, int page, int size) { + if (userId == null) { + throw new BoardException(INVALID_REQUEST); + } + validateBoardListRequest(page, size); + + int offset = page * size; + List queryResponses = boardMapper.findMyBoardList(userId, offset, size); + long totalElements = boardMapper.countMyBoards(userId); + + return BoardListResponse.toDto( + queryResponses, + totalElements, + page, + size, + "내가 쓴 게시글 목록을 성공적으로 조회했습니다." + ); + } + /** * 새 게시글을 생성하고 요청에 포함된 이미지 URL 목록을 게시글에 연결합니다. * diff --git a/src/main/java/com/dodo/backend/comment/controller/CommentController.java b/src/main/java/com/dodo/backend/comment/controller/CommentController.java index 661af80..a20f54d 100644 --- a/src/main/java/com/dodo/backend/comment/controller/CommentController.java +++ b/src/main/java/com/dodo/backend/comment/controller/CommentController.java @@ -5,6 +5,7 @@ import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; import com.dodo.backend.comment.service.CommentService; import com.dodo.backend.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; @@ -74,6 +75,37 @@ public ResponseEntity createComment( return ResponseEntity.ok(commentService.createComment(userId, request)); } + /** + * 요청 사용자가 작성한 댓글 목록을 조회합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 내가 쓴 댓글 목록 조회 응답 + */ + @Operation(summary = "내가 쓴 댓글 목록 조회", description = "인증된 사용자가 작성한 댓글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "내가 쓴 댓글 목록을 성공적으로 조회했습니다.", + content = @Content(schema = @Schema(implementation = MyCommentListResponse.class))), + @ApiResponse(responseCode = "400", description = "?섎せ???붿껌?낅땲??", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "濡쒓렇?몄씠 ?꾩슂??湲곕뒫?낅땲??", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "?쒕쾭 ?대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me") + public ResponseEntity getMyComments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("내가 쓴 댓글 목록 조회 요청 수신 - User: {}, Page: {}, Size: {}", userId, page, size); + + return ResponseEntity.ok(commentService.getMyComments(userId, page, size)); + } + /** * 특정 게시글의 댓글 목록을 조회합니다. * diff --git a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java index c68110c..170ae21 100644 --- a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java +++ b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java @@ -293,4 +293,141 @@ public static CommentSimpleResponse toDto(String message) { .build(); } } + + /** + * 내가 쓴 댓글 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "내가 쓴 댓글 목록 조회 응답") + public static class MyCommentListResponse { + + @Schema(description = "응답 메시지", example = "내가 쓴 댓글 목록을 성공적으로 조회했습니다.") + private String message; + + @Schema(description = "페이지 정보") + private PageInfoResponse pageInfo; + + @Schema(description = "내가 쓴 댓글 목록") + private List data; + + /** + * MyBatis 조회 결과를 내가 쓴 댓글 목록 조회 응답 DTO로 변환합니다. + * + * @param queryResponses 내가 쓴 댓글 목록 조회 결과 + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 댓글 수 + * @param message 응답 메시지 + * @return 내가 쓴 댓글 목록 조회 응답 DTO + */ + public static MyCommentListResponse toDto( + List queryResponses, + int page, + int size, + long totalElements, + String message + ) { + List data = queryResponses == null + ? List.of() + : queryResponses.stream() + .map(MyCommentItemResponse::toDto) + .toList(); + + return MyCommentListResponse.builder() + .message(message) + .pageInfo(PageInfoResponse.toDto(page, size, totalElements)) + .data(data) + .build(); + } + } + + /** + * 내가 쓴 댓글 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "내가 쓴 댓글 목록 아이템 응답") + public static class MyCommentItemResponse { + + @Schema(description = "댓글 ID", example = "102") + private Long commentId; + + @Schema(description = "게시글 ID", example = "1") + private Long boardId; + + @Schema(description = "게시글 제목", example = "우리 강아지 자랑합니다") + private String boardTitle; + + @Schema(description = "부모 댓글 ID", example = "null") + private Long parentCommentId; + + @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다.") + private String commentContent; + + @Schema(description = "댓글 작성 일시", example = "2025-10-14T14:30:00") + private LocalDateTime createdAt; + + /** + * 내가 쓴 댓글 목록 조회 결과를 응답 아이템 DTO로 변환합니다. + * + * @param queryResponse 내가 쓴 댓글 목록 조회 결과 + * @return 내가 쓴 댓글 목록 아이템 응답 DTO + */ + public static MyCommentItemResponse toDto(MyCommentListQueryResponse queryResponse) { + return MyCommentItemResponse.builder() + .commentId(queryResponse.getCommentId()) + .boardId(queryResponse.getBoardId()) + .boardTitle(queryResponse.getBoardTitle()) + .parentCommentId(queryResponse.getParentCommentId()) + .commentContent(queryResponse.getCommentContent()) + .createdAt(queryResponse.getCreatedAt()) + .build(); + } + } + + /** + * 내가 쓴 댓글 목록 조회 MyBatis 결과 DTO입니다. + */ + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyCommentListQueryResponse { + + /** + * 댓글 ID입니다. + */ + private Long commentId; + + /** + * 게시글 ID입니다. + */ + private Long boardId; + + /** + * 게시글 제목입니다. + */ + private String boardTitle; + + /** + * 부모 댓글 ID입니다. + */ + private Long parentCommentId; + + /** + * 댓글 내용입니다. + */ + private String commentContent; + + /** + * 댓글 작성 일시입니다. + */ + private LocalDateTime createdAt; + } } diff --git a/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java index b890dbe..4a51afc 100644 --- a/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java +++ b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java @@ -1,10 +1,12 @@ package com.dodo.backend.comment.mapper; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListQueryResponse; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; +import java.util.UUID; /** * 댓글(Comment) 도메인의 목록 조회 및 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. @@ -34,6 +36,28 @@ List findCommentsByBoardId( */ long countCommentsByBoardId(@Param("boardId") Long boardId); + /** + * 특정 사용자가 작성한 댓글 목록을 페이지 단위로 조회합니다. + * + * @param userId 조회할 사용자 ID + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 내가 쓴 댓글 목록 조회 결과 + */ + List findMyComments( + @Param("userId") UUID userId, + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 특정 사용자가 작성한 댓글 전체 개수를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 내가 쓴 댓글 전체 개수 + */ + long countMyComments(@Param("userId") UUID userId); + /** * 댓글 내용을 수정합니다. * diff --git a/src/main/java/com/dodo/backend/comment/service/CommentService.java b/src/main/java/com/dodo/backend/comment/service/CommentService.java index 5f01e58..fcd2423 100644 --- a/src/main/java/com/dodo/backend/comment/service/CommentService.java +++ b/src/main/java/com/dodo/backend/comment/service/CommentService.java @@ -5,6 +5,7 @@ import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; import com.dodo.backend.comment.exception.CommentException; import java.util.UUID; @@ -34,6 +35,16 @@ public interface CommentService { */ CommentListResponse getComments(Long boardId, int page, int size); + /** + * 요청 사용자가 작성한 댓글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 댓글 목록 조회 응답 DTO + */ + MyCommentListResponse getMyComments(UUID userId, int page, int size); + /** * 특정 댓글을 수정합니다. * diff --git a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java index e4bb7b6..d9d94ea 100644 --- a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java @@ -9,6 +9,8 @@ import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.comment.exception.CommentErrorCode; import com.dodo.backend.comment.exception.CommentException; @@ -103,6 +105,35 @@ public CommentListResponse getComments(Long boardId, int page, int size) { ); } + /** + * 요청 사용자가 작성한 댓글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 댓글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public MyCommentListResponse getMyComments(UUID userId, int page, int size) { + if (userId == null) { + throw new CommentException(INVALID_REQUEST); + } + validateMyCommentListRequest(page, size); + + int offset = page * size; + List queryResponses = commentMapper.findMyComments(userId, offset, size); + long totalElements = commentMapper.countMyComments(userId); + + return MyCommentListResponse.toDto( + queryResponses, + page, + size, + totalElements, + "내가 쓴 댓글 목록을 성공적으로 조회했습니다." + ); + } + /** * 특정 댓글을 수정합니다. * @@ -211,6 +242,18 @@ private void validateCommentListRequest(Long boardId, int page, int size) { } } + /** + * 내가 쓴 댓글 목록 조회 요청 값을 검증합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + */ + private void validateMyCommentListRequest(int page, int size) { + if (page < 0 || size <= 0 || size > MAX_COMMENT_LIST_SIZE || page > Integer.MAX_VALUE / size) { + throw new CommentException(INVALID_REQUEST); + } + } + /** * 댓글 작성자인지 검증합니다. * diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index bfce201..73abef1 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -3,14 +3,104 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - - + + + + + + + + UPDATE board @@ -21,19 +111,12 @@ WHERE board_id = #{boardId} - UPDATE board SET view_count = view_count + 1 WHERE board_id = #{boardId} - UPDATE board SET board_status = #{status}, diff --git a/src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml b/src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml new file mode 100644 index 0000000..9c9367c --- /dev/null +++ b/src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + UPDATE comment + SET comment_content = #{commentContent}, + comment_modified_at = NOW() + WHERE comment_id = #{commentId} + + diff --git a/src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java b/src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java new file mode 100644 index 0000000..dc25c71 --- /dev/null +++ b/src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java @@ -0,0 +1,88 @@ +package com.dodo.backend.board.controller; + +import com.dodo.backend.board.dto.response.BoardResponse.BoardListItemResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; +import com.dodo.backend.board.service.BoardService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 게시글 목록 조회 컨트롤러 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class BoardMyBoardControllerTest { + + @Mock + private BoardService boardService; + + /** + * 내가 쓴 게시글 목록 조회 요청 시 200 상태 코드와 목록 응답을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 게시글 목록 조회 성공: 200 상태 코드와 목록을 반환한다.") + void getMyBoards_Success() { + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + BoardListResponse serviceResponse = createBoardListResponse(); + + given(boardService.getMyBoards(userId, 0, 10)).willReturn(serviceResponse); + + ResponseEntity response = boardController.getMyBoards(0, 10, userDetails); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().getBoards().size()); + assertEquals(1L, response.getBody().getBoards().get(0).getBoardId()); + + verify(boardService).getMyBoards(userId, 0, 10); + } + + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } + + private BoardListResponse createBoardListResponse() { + BoardListItemResponse itemResponse = BoardListItemResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContentPreview("오늘 산책하다가 찍은 사진이에요. 너") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(12L) + .dislikeCount(1L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + return BoardListResponse.builder() + .message("내가 쓴 게시글 목록을 성공적으로 조회했습니다.") + .boards(List.of(itemResponse)) + .totalPages(1) + .totalElements(1L) + .currentPage(0) + .pageSize(10) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java new file mode 100644 index 0000000..f2eea78 --- /dev/null +++ b/src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java @@ -0,0 +1,103 @@ +package com.dodo.backend.board.service; + +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; +import com.dodo.backend.board.exception.BoardErrorCode; +import com.dodo.backend.board.exception.BoardException; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +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.redis.core.RedisTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 게시글 목록 조회 비즈니스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class BoardMyBoardServiceTest { + + @InjectMocks + private BoardServiceImpl boardService; + + @Mock + private BoardRepository boardRepository; + + @Mock + private UserService userService; + + @Mock + private ImageFileService imageFileService; + + @Mock + private BoardMapper boardMapper; + + @Mock + private RedisTemplate redisTemplate; + + /** + * 내가 쓴 게시글 목록 조회 시 페이지 정보와 게시글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 게시글 목록 조회 성공: 게시글 목록과 페이지 정보를 반환한다.") + void getMyBoards_Success() { + UUID userId = UUID.randomUUID(); + BoardListQueryResponse queryResponse = BoardListQueryResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContent("오늘 산책하다가 찍은 사진이에요. 너무 귀엽죠?") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(12L) + .dislikeCount(1L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + given(boardMapper.findMyBoardList(userId, 0, 10)).willReturn(List.of(queryResponse)); + given(boardMapper.countMyBoards(userId)).willReturn(1L); + + BoardListResponse response = boardService.getMyBoards(userId, 0, 10); + + assertNotNull(response); + assertEquals("내가 쓴 게시글 목록을 성공적으로 조회했습니다.", response.getMessage()); + assertEquals(1, response.getBoards().size()); + assertEquals(1L, response.getBoards().get(0).getBoardId()); + assertEquals(1L, response.getTotalElements()); + assertEquals(1, response.getTotalPages()); + + verify(boardMapper).findMyBoardList(userId, 0, 10); + verify(boardMapper).countMyBoards(userId); + } + + /** + * 사용자 ID가 없으면 잘못된 요청 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 게시글 목록 조회 실패: 사용자 ID가 없으면 예외가 발생한다.") + void getMyBoards_Fail_UserIdNull() { + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getMyBoards(null, 0, 10)); + + assertEquals(BoardErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(boardMapper, never()).findMyBoardList(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } +} diff --git a/src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java b/src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java new file mode 100644 index 0000000..30f3691 --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java @@ -0,0 +1,82 @@ +package com.dodo.backend.comment.controller; + +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentItemResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.PageInfoResponse; +import com.dodo.backend.comment.service.CommentService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 댓글 목록 조회 컨트롤러 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class CommentMyCommentControllerTest { + + @Mock + private CommentService commentService; + + /** + * 내가 쓴 댓글 목록 조회 요청 시 200 상태 코드와 목록 응답을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 댓글 목록 조회 성공: 200 상태 코드와 목록을 반환한다.") + void getMyComments_Success() { + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + MyCommentListResponse serviceResponse = createMyCommentListResponse(); + + given(commentService.getMyComments(userId, 0, 10)).willReturn(serviceResponse); + + ResponseEntity response = commentController.getMyComments(0, 10, userDetails); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().getData().size()); + assertEquals(10L, response.getBody().getData().get(0).getCommentId()); + assertEquals(1L, response.getBody().getData().get(0).getBoardId()); + + verify(commentService).getMyComments(userId, 0, 10); + } + + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } + + private MyCommentListResponse createMyCommentListResponse() { + MyCommentItemResponse itemResponse = MyCommentItemResponse.builder() + .commentId(10L) + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .parentCommentId(null) + .commentContent("좋은 정보 감사합니다.") + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .build(); + + return MyCommentListResponse.builder() + .message("내가 쓴 댓글 목록을 성공적으로 조회했습니다.") + .pageInfo(PageInfoResponse.toDto(0, 10, 1L)) + .data(List.of(itemResponse)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java b/src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java new file mode 100644 index 0000000..ba38f0a --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java @@ -0,0 +1,94 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; +import com.dodo.backend.comment.exception.CommentErrorCode; +import com.dodo.backend.comment.exception.CommentException; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 댓글 목록 조회 비즈니스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class CommentMyCommentServiceTest { + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentMapper commentMapper; + + @Mock + private BoardService boardService; + + @Mock + private UserService userService; + + /** + * 내가 쓴 댓글 목록 조회 시 게시글 정보가 포함된 댓글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 댓글 목록 조회 성공: 게시글 정보가 포함된 댓글 목록을 반환한다.") + void getMyComments_Success() { + UUID userId = UUID.randomUUID(); + MyCommentListQueryResponse queryResponse = MyCommentListQueryResponse.builder() + .commentId(10L) + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .parentCommentId(null) + .commentContent("좋은 정보 감사합니다.") + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .build(); + + given(commentMapper.findMyComments(userId, 0, 10)).willReturn(List.of(queryResponse)); + given(commentMapper.countMyComments(userId)).willReturn(1L); + + MyCommentListResponse response = commentService.getMyComments(userId, 0, 10); + + assertNotNull(response); + assertEquals("내가 쓴 댓글 목록을 성공적으로 조회했습니다.", response.getMessage()); + assertEquals(1, response.getData().size()); + assertEquals(10L, response.getData().get(0).getCommentId()); + assertEquals(1L, response.getData().get(0).getBoardId()); + assertEquals("우리 강아지 자랑합니다", response.getData().get(0).getBoardTitle()); + + verify(commentMapper).findMyComments(userId, 0, 10); + verify(commentMapper).countMyComments(userId); + } + + /** + * 사용자 ID가 없으면 잘못된 요청 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 댓글 목록 조회 실패: 사용자 ID가 없으면 예외가 발생한다.") + void getMyComments_Fail_UserIdNull() { + CommentException exception = assertThrows(CommentException.class, + () -> commentService.getMyComments(null, 0, 10)); + + assertEquals(CommentErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(commentMapper, never()).findMyComments(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } +} From 3d824a55965715b783df70c703217a34c02ec458 Mon Sep 17 00:00:00 2001 From: limhb708 Date: Fri, 12 Jun 2026 21:13:05 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=A7=A4=ED=8D=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BoardMapper.xml 내 중복 findBoardList 쿼리 제거 - 중복 countPublishedBoards 쿼리 제거 - 게시글 목록 조회 시 FREE 타입 조건 유지 - MyBatis 매퍼 중복 등록으로 인한 CI 실패 수정 Closes #168 --- .../board/controller/BoardController.java | 6 +- .../comment/controller/CommentController.java | 6 +- .../dodo/backend/board/mapper/BoardMapper.xml | 64 +------------------ 3 files changed, 7 insertions(+), 69 deletions(-) diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index 73126a3..227976b 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -87,11 +87,11 @@ public ResponseEntity getBoardList( @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "내가 쓴 게시글 목록을 성공적으로 조회했습니다.", content = @Content(schema = @Schema(implementation = BoardListResponse.class))), - @ApiResponse(responseCode = "400", description = "?섎せ???붿껌?낅땲??", + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "401", description = "濡쒓렇?몄씠 ?꾩슂??湲곕뒫?낅땲??", + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "500", description = "?쒕쾭 ?대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.", + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @GetMapping("/me") diff --git a/src/main/java/com/dodo/backend/comment/controller/CommentController.java b/src/main/java/com/dodo/backend/comment/controller/CommentController.java index a20f54d..d85da27 100644 --- a/src/main/java/com/dodo/backend/comment/controller/CommentController.java +++ b/src/main/java/com/dodo/backend/comment/controller/CommentController.java @@ -87,11 +87,11 @@ public ResponseEntity createComment( @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "내가 쓴 댓글 목록을 성공적으로 조회했습니다.", content = @Content(schema = @Schema(implementation = MyCommentListResponse.class))), - @ApiResponse(responseCode = "400", description = "?섎せ???붿껌?낅땲??", + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "401", description = "濡쒓렇?몄씠 ?꾩슂??湲곕뒫?낅땲??", + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "500", description = "?쒕쾭 ?대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.", + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @GetMapping("/me") diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index 73abef1..6176c37 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -41,6 +41,7 @@ FROM board b INNER JOIN users u ON u.users_id = b.user_id WHERE b.board_status = 'PUBLISHED' + AND b.board_type = 'FREE' ORDER BY b.board_created_at DESC, b.board_id DESC LIMIT #{size} OFFSET #{offset} @@ -123,67 +124,4 @@ board_status_updated_at = NOW() WHERE board_id = #{boardId} - - - - - - - From 0de8096bbd10831a852c3548f16b035e90af65d7 Mon Sep 17 00:00:00 2001 From: limhb708 Date: Fri, 12 Jun 2026 21:34:20 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=A7=A4=ED=8D=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - develop 병합 후 재발생한 BoardMapper.xml 중복 쿼리 제거 --- .../dodo/backend/board/mapper/BoardMapper.xml | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index 6afae90..6176c37 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -124,67 +124,4 @@ board_status_updated_at = NOW() WHERE board_id = #{boardId} - - - - - - - From eaecfbeb7c9d094feee71a099a89e10f2fbbab2d Mon Sep 17 00:00:00 2001 From: limhb708 Date: Sat, 13 Jun 2026 08:44:19 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EB=A7=A4=ED=8D=BC=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentServiceImpl Transactional 어노테이션 순서 정리 - CommentResponse Swagger 어노테이션 제거 및 Javadoc 정리 - comment 도메인 미사용 DTO 및 메서드 참조 확인 - BoardMapper.xml 중복 findBoardList/countPublishedBoards 쿼리 제거 --- .../comment/dto/response/CommentResponse.java | 127 ++++++++++++------ .../comment/service/CommentServiceImpl.java | 10 +- .../dodo/backend/board/mapper/BoardMapper.xml | 63 --------- 3 files changed, 92 insertions(+), 108 deletions(-) diff --git a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java index f0be690..73b1c33 100644 --- a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java +++ b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java @@ -1,7 +1,6 @@ package com.dodo.backend.comment.dto.response; import com.dodo.backend.comment.entity.Comment; -import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,7 +13,6 @@ /** * 댓글 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. */ -@Schema(description = "댓글 응답 DTO 그룹") public class CommentResponse { /** @@ -24,22 +22,31 @@ public class CommentResponse { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 작성 응답") public static class CommentCreateResponse { - @Schema(description = "응답 메시지", example = "댓글이 성공적으로 작성되었습니다.") + /** + * 응답 메시지입니다. + */ private String message; - @Schema(description = "댓글 ID", example = "123") + /** + * 생성된 댓글 ID입니다. + */ private Long commentId; - @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다!") + /** + * 생성된 댓글 내용입니다. + */ private String commentContent; - @Schema(description = "작성자 ID", example = "uuid-user-1") + /** + * 댓글 작성자 ID입니다. + */ private String userId; - @Schema(description = "작성자 닉네임", example = "멍멍이집사") + /** + * 댓글 작성자 닉네임입니다. + */ private String nickname; /** @@ -67,16 +74,21 @@ public static CommentCreateResponse toDto(Comment comment, String message) { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 목록 조회 응답") public static class CommentListResponse { - @Schema(description = "응답 메시지", example = "댓글 목록을 성공적으로 조회했습니다.") + /** + * 응답 메시지입니다. + */ private String message; - @Schema(description = "페이지 정보") + /** + * 페이지 정보입니다. + */ private PageInfoResponse pageInfo; - @Schema(description = "댓글 목록") + /** + * 댓글 목록입니다. + */ private List data; /** @@ -117,19 +129,26 @@ public static CommentListResponse toDto( @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "페이지 정보 응답") public static class PageInfoResponse { - @Schema(description = "현재 페이지 번호", example = "0") + /** + * 현재 페이지 번호입니다. + */ private int page; - @Schema(description = "페이지 크기", example = "10") + /** + * 페이지 크기입니다. + */ private int size; - @Schema(description = "전체 요소 수", example = "48") + /** + * 전체 요소 수입니다. + */ private long totalElements; - @Schema(description = "전체 페이지 수", example = "5") + /** + * 전체 페이지 수입니다. + */ private int totalPages; /** @@ -161,22 +180,31 @@ public static PageInfoResponse toDto(int page, int size, long totalElements) { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 목록 아이템 응답") public static class CommentItemResponse { - @Schema(description = "댓글 ID", example = "102") + /** + * 댓글 ID입니다. + */ private Long commentId; - @Schema(description = "부모 댓글 ID", example = "null") + /** + * 부모 댓글 ID입니다. + */ private Long parentCommentId; - @Schema(description = "댓글 내용", example = "두 번째 댓글입니다.") + /** + * 댓글 내용입니다. + */ private String commentContent; - @Schema(description = "작성자 정보") + /** + * 댓글 작성자 정보입니다. + */ private CommentAuthorResponse author; - @Schema(description = "댓글 작성 일시", example = "2025-10-14T14:30:00") + /** + * 댓글 작성 일시입니다. + */ private LocalDateTime createdAt; /** @@ -203,13 +231,16 @@ public static CommentItemResponse toDto(CommentListQueryResponse queryResponse) @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 작성자 응답") public static class CommentAuthorResponse { - @Schema(description = "작성자 ID", example = "uuid-user-1") + /** + * 작성자 ID입니다. + */ private String userId; - @Schema(description = "작성자 닉네임", example = "행복한강아지") + /** + * 작성자 닉네임입니다. + */ private String nickname; /** @@ -275,10 +306,11 @@ public static class CommentListQueryResponse { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 단순 처리 응답") public static class CommentSimpleResponse { - @Schema(description = "응답 메시지", example = "댓글이 성공적으로 수정되었습니다.") + /** + * 응답 메시지입니다. + */ private String message; /** @@ -301,16 +333,21 @@ public static CommentSimpleResponse toDto(String message) { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "내가 쓴 댓글 목록 조회 응답") public static class MyCommentListResponse { - @Schema(description = "응답 메시지", example = "내가 쓴 댓글 목록을 성공적으로 조회했습니다.") + /** + * 응답 메시지입니다. + */ private String message; - @Schema(description = "페이지 정보") + /** + * 페이지 정보입니다. + */ private PageInfoResponse pageInfo; - @Schema(description = "내가 쓴 댓글 목록") + /** + * 내가 쓴 댓글 목록입니다. + */ private List data; /** @@ -351,25 +388,36 @@ public static MyCommentListResponse toDto( @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "내가 쓴 댓글 목록 아이템 응답") public static class MyCommentItemResponse { - @Schema(description = "댓글 ID", example = "102") + /** + * 댓글 ID입니다. + */ private Long commentId; - @Schema(description = "게시글 ID", example = "1") + /** + * 댓글이 작성된 게시글 ID입니다. + */ private Long boardId; - @Schema(description = "게시글 제목", example = "우리 강아지 자랑합니다") + /** + * 댓글이 작성된 게시글 제목입니다. + */ private String boardTitle; - @Schema(description = "부모 댓글 ID", example = "null") + /** + * 부모 댓글 ID입니다. + */ private Long parentCommentId; - @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다.") + /** + * 댓글 내용입니다. + */ private String commentContent; - @Schema(description = "댓글 작성 일시", example = "2025-10-14T14:30:00") + /** + * 댓글 작성 일시입니다. + */ private LocalDateTime createdAt; /** @@ -430,5 +478,4 @@ public static class MyCommentListQueryResponse { */ private LocalDateTime createdAt; } - } diff --git a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java index d9d94ea..48b378b 100644 --- a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java @@ -57,8 +57,8 @@ public class CommentServiceImpl implements CommentService { * @return 댓글 작성 응답 DTO * @throws CommentException 잘못된 요청 또는 부모 댓글이 없는 경우 */ - @Override @Transactional + @Override public CommentCreateResponse createComment(UUID userId, CommentCreateRequest request) { if (userId == null || request == null || isBlank(request.getCommentContent())) { throw new CommentException(INVALID_REQUEST); @@ -84,8 +84,8 @@ public CommentCreateResponse createComment(UUID userId, CommentCreateRequest req * @param size 페이지 크기 * @return 댓글 목록 조회 응답 DTO */ - @Override @Transactional(readOnly = true) + @Override public CommentListResponse getComments(Long boardId, int page, int size) { validateCommentListRequest(boardId, page, size); @@ -113,8 +113,8 @@ public CommentListResponse getComments(Long boardId, int page, int size) { * @param size 페이지 크기 * @return 내가 쓴 댓글 목록 조회 응답 DTO */ - @Override @Transactional(readOnly = true) + @Override public MyCommentListResponse getMyComments(UUID userId, int page, int size) { if (userId == null) { throw new CommentException(INVALID_REQUEST); @@ -143,8 +143,8 @@ public MyCommentListResponse getMyComments(UUID userId, int page, int size) { * @return 댓글 수정 응답 DTO * @throws CommentException 잘못된 요청, 댓글 없음, 수정 권한 없음인 경우 */ - @Override @Transactional + @Override public CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentUpdateRequest request) { if (userId == null || request == null || isBlank(request.getCommentContent())) { throw new CommentException(INVALID_REQUEST); @@ -168,8 +168,8 @@ public CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentU * @return 댓글 삭제 응답 DTO * @throws CommentException 잘못된 요청, 댓글 없음, 삭제 권한 없음인 경우 */ - @Override @Transactional + @Override public CommentSimpleResponse deleteComment(UUID userId, Long commentId) { if (userId == null) { throw new CommentException(INVALID_REQUEST); diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index 6afae90..6176c37 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -124,67 +124,4 @@ board_status_updated_at = NOW() WHERE board_id = #{boardId} - - - - - - - From c69ea67fb9c931bb4c934156f17c12eb4a288e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=ED=98=84=EB=B9=88?= <63556578+limhb708@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:28:28 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=91=EC=84=B1=ED=95=9C=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80,=20=EB=8C=93=EA=B8=80=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/controller/BoardController.java | 32 +++ .../board/dto/response/BoardResponse.java | 23 +- .../backend/board/mapper/BoardMapper.java | 23 ++ .../backend/board/service/BoardService.java | 10 + .../board/service/BoardServiceImpl.java | 73 +++--- .../comment/controller/CommentController.java | 32 +++ .../comment/dto/response/CommentResponse.java | 241 ++++++++++++++++-- .../backend/comment/mapper/CommentMapper.java | 25 +- .../comment/service/CommentService.java | 11 + .../comment/service/CommentServiceImpl.java | 51 +++- .../repository/ReactionRepository.java | 9 + .../dodo/backend/board/mapper/BoardMapper.xml | 169 ++++++------ .../backend/comment/mapper/CommentMapper.xml | 61 +++++ .../BoardMyBoardControllerTest.java | 88 +++++++ .../service/BoardMyBoardServiceTest.java | 103 ++++++++ .../board/service/BoardServiceTest.java | 15 ++ .../CommentMyCommentControllerTest.java | 82 ++++++ .../service/CommentMyCommentServiceTest.java | 94 +++++++ 18 files changed, 1004 insertions(+), 138 deletions(-) create mode 100644 src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml create mode 100644 src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java create mode 100644 src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java create mode 100644 src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java create mode 100644 src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index f2c4de7..55ce059 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -74,6 +74,38 @@ public ResponseEntity getBoardList( return ResponseEntity.ok(boardService.getBoardList(page, size)); } + + /** + * 요청 사용자가 작성한 게시글 목록을 조회합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 내가 쓴 게시글 목록 조회 응답 + */ + @Operation(summary = "내가 쓴 게시글 목록 조회", description = "인증된 사용자가 작성한 게시글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "내가 쓴 게시글 목록을 성공적으로 조회했습니다.", + content = @Content(schema = @Schema(implementation = BoardListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me") + public ResponseEntity getMyBoards( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("내가 쓴 게시글 목록 조회 요청 수신 - User: {}, Page: {}, Size: {}", userId, page, size); + + return ResponseEntity.ok(boardService.getMyBoards(userId, page, size)); + } + /** * 새 게시글을 작성합니다. * diff --git a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java index fa81451..fdceb5e 100644 --- a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java +++ b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java @@ -287,6 +287,12 @@ public static class BoardDetailResponse { @Schema(description = "조회수", example = "51") private Integer viewCount; + @Schema(description = "좋아요 수", example = "12") + private Long likeCount; + + @Schema(description = "싫어요 수", example = "1") + private Long dislikeCount; + @Schema(description = "게시글 생성 일시", example = "2025-10-06T10:00:00") private LocalDateTime boardCreatedAt; @@ -300,9 +306,18 @@ public static class BoardDetailResponse { * @param imageFileUrls 게시글 이미지 URL 목록 * @param message 응답 메시지 * @param viewCount 응답에 표시할 조회수 + * @param likeCount 좋아요 수 + * @param dislikeCount 싫어요 수 * @return 게시글 상세 조회 응답 DTO */ - public static BoardDetailResponse toDto(Board board, List imageFileUrls, String message, Integer viewCount) { + public static BoardDetailResponse toDto( + Board board, + List imageFileUrls, + String message, + Integer viewCount, + Long likeCount, + Long dislikeCount + ) { return BoardDetailResponse.builder() .message(message) .boardId(board.getBoardId()) @@ -312,10 +327,16 @@ public static BoardDetailResponse toDto(Board board, List imageFileUrls, .nickname(board.getUser().getNickname()) .profileUrl(board.getUser().getProfileUrl()) .viewCount(viewCount) + .likeCount(defaultZero(likeCount)) + .dislikeCount(defaultZero(dislikeCount)) .boardCreatedAt(board.getBoardCreatedAt()) .modifiedAt(board.getModifiedAt()) .build(); } + + private static Long defaultZero(Long value) { + return value == null ? 0L : value; + } } /** diff --git a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java index a97ac8d..a0c8606 100644 --- a/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java +++ b/src/main/java/com/dodo/backend/board/mapper/BoardMapper.java @@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +import java.util.UUID; /** * 게시글(Board) 도메인의 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. @@ -32,6 +33,28 @@ List findBoardList( */ long countPublishedBoards(); + /** + * 특정 사용자가 작성한 게시글 목록을 페이지 단위로 조회합니다. + * + * @param userId 조회할 사용자 ID + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 내가 쓴 게시글 목록 조회 결과 + */ + List findMyBoardList( + @Param("userId") UUID userId, + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 특정 사용자가 작성한 게시글 전체 개수를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 내가 쓴 게시글 전체 개수 + */ + long countMyBoards(@Param("userId") UUID userId); + /** * 게시글 제목과 내용을 선택적으로 수정합니다. * diff --git a/src/main/java/com/dodo/backend/board/service/BoardService.java b/src/main/java/com/dodo/backend/board/service/BoardService.java index 9056f15..8eae232 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -36,6 +36,16 @@ public interface BoardService { */ BoardListResponse getBoardList(int page, int size); + /** + * 요청 사용자가 작성한 게시글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 게시글 목록 조회 응답 DTO + */ + BoardListResponse getMyBoards(UUID userId, int page, int size); + /** * 새 게시글을 생성하고 이미지 URL 목록을 게시글에 연결합니다. * diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index 73cc6cf..e309a69 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -15,6 +15,8 @@ import com.dodo.backend.board.mapper.BoardMapper; import com.dodo.backend.board.repository.BoardRepository; import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.reaction.entity.ReactionType; +import com.dodo.backend.reaction.repository.ReactionRepository; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -47,41 +49,14 @@ @RequiredArgsConstructor public class BoardServiceImpl implements BoardService { - /** - * Redis에 임시 저장 게시글 데이터를 저장할 때 사용하는 키 접두사입니다. - */ private static final String TEMP_SAVE_KEY_PREFIX = "board:temp-save:"; - - /** - * 임시 저장 데이터의 Redis 유지 기간입니다. - */ private static final long TEMP_SAVE_TTL_DAYS = 7L; - private static final int MAX_BOARD_LIST_SIZE = 100; - - /** - * 게시글 저장 및 단건 조회를 처리하는 JPA Repository입니다. - */ private final BoardRepository boardRepository; - - /** - * 사용자 엔티티 조회를 처리하는 서비스입니다. - */ private final UserService userService; - - /** - * 게시글 이미지 URL 저장, 조회, 교체, 삭제를 처리하는 서비스입니다. - */ private final ImageFileService imageFileService; - - /** - * 게시글 수정, 삭제, 조회수 증가처럼 동적 SQL이 필요한 작업을 처리하는 MyBatis Mapper입니다. - */ private final BoardMapper boardMapper; - - /** - * 게시글 임시 저장 데이터를 Redis에 저장하고 조회하기 위한 Template입니다. - */ + private final ReactionRepository reactionRepository; private final RedisTemplate redisTemplate; /** @@ -124,6 +99,35 @@ public BoardListResponse getBoardList(int page, int size) { ); } + /** + * 요청 사용자가 작성한 게시글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 게시글 목록 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public BoardListResponse getMyBoards(UUID userId, int page, int size) { + if (userId == null) { + throw new BoardException(INVALID_REQUEST); + } + validateBoardListRequest(page, size); + + int offset = page * size; + List queryResponses = boardMapper.findMyBoardList(userId, offset, size); + long totalElements = boardMapper.countMyBoards(userId); + + return BoardListResponse.toDto( + queryResponses, + totalElements, + page, + size, + "내가 쓴 게시글 목록을 성공적으로 조회했습니다." + ); + } + /** * 새 게시글을 생성하고 요청에 포함된 이미지 URL 목록을 게시글에 연결합니다. * @@ -173,8 +177,17 @@ public BoardDetailResponse getBoardDetail(UUID userId, Long boardId) { } var imageFileUrls = imageFileService.getBoardImageUrls(boardId); - - return BoardDetailResponse.toDto(board, imageFileUrls, "게시글 상세 조회에 성공했습니다.", responseViewCount); + long likeCount = reactionRepository.countByBoard_BoardIdAndReactionType(boardId, ReactionType.LIKE); + long dislikeCount = reactionRepository.countByBoard_BoardIdAndReactionType(boardId, ReactionType.DISLIKE); + + return BoardDetailResponse.toDto( + board, + imageFileUrls, + "게시글 상세 조회에 성공했습니다.", + responseViewCount, + likeCount, + dislikeCount + ); } /** diff --git a/src/main/java/com/dodo/backend/comment/controller/CommentController.java b/src/main/java/com/dodo/backend/comment/controller/CommentController.java index 661af80..d85da27 100644 --- a/src/main/java/com/dodo/backend/comment/controller/CommentController.java +++ b/src/main/java/com/dodo/backend/comment/controller/CommentController.java @@ -5,6 +5,7 @@ import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; import com.dodo.backend.comment.service.CommentService; import com.dodo.backend.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; @@ -74,6 +75,37 @@ public ResponseEntity createComment( return ResponseEntity.ok(commentService.createComment(userId, request)); } + /** + * 요청 사용자가 작성한 댓글 목록을 조회합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param userDetails 인증된 사용자 정보 + * @return 내가 쓴 댓글 목록 조회 응답 + */ + @Operation(summary = "내가 쓴 댓글 목록 조회", description = "인증된 사용자가 작성한 댓글 목록을 페이지 단위로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "내가 쓴 댓글 목록을 성공적으로 조회했습니다.", + content = @Content(schema = @Schema(implementation = MyCommentListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/me") + public ResponseEntity getMyComments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("내가 쓴 댓글 목록 조회 요청 수신 - User: {}, Page: {}, Size: {}", userId, page, size); + + return ResponseEntity.ok(commentService.getMyComments(userId, page, size)); + } + /** * 특정 게시글의 댓글 목록을 조회합니다. * diff --git a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java index c68110c..73b1c33 100644 --- a/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java +++ b/src/main/java/com/dodo/backend/comment/dto/response/CommentResponse.java @@ -1,7 +1,6 @@ package com.dodo.backend.comment.dto.response; import com.dodo.backend.comment.entity.Comment; -import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,7 +13,6 @@ /** * 댓글 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. */ -@Schema(description = "댓글 응답 DTO 그룹") public class CommentResponse { /** @@ -24,22 +22,31 @@ public class CommentResponse { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 작성 응답") public static class CommentCreateResponse { - @Schema(description = "응답 메시지", example = "댓글이 성공적으로 작성되었습니다.") + /** + * 응답 메시지입니다. + */ private String message; - @Schema(description = "댓글 ID", example = "123") + /** + * 생성된 댓글 ID입니다. + */ private Long commentId; - @Schema(description = "댓글 내용", example = "좋은 정보 감사합니다!") + /** + * 생성된 댓글 내용입니다. + */ private String commentContent; - @Schema(description = "작성자 ID", example = "uuid-user-1") + /** + * 댓글 작성자 ID입니다. + */ private String userId; - @Schema(description = "작성자 닉네임", example = "멍멍이집사") + /** + * 댓글 작성자 닉네임입니다. + */ private String nickname; /** @@ -67,16 +74,21 @@ public static CommentCreateResponse toDto(Comment comment, String message) { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 목록 조회 응답") public static class CommentListResponse { - @Schema(description = "응답 메시지", example = "댓글 목록을 성공적으로 조회했습니다.") + /** + * 응답 메시지입니다. + */ private String message; - @Schema(description = "페이지 정보") + /** + * 페이지 정보입니다. + */ private PageInfoResponse pageInfo; - @Schema(description = "댓글 목록") + /** + * 댓글 목록입니다. + */ private List data; /** @@ -117,19 +129,26 @@ public static CommentListResponse toDto( @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "페이지 정보 응답") public static class PageInfoResponse { - @Schema(description = "현재 페이지 번호", example = "0") + /** + * 현재 페이지 번호입니다. + */ private int page; - @Schema(description = "페이지 크기", example = "10") + /** + * 페이지 크기입니다. + */ private int size; - @Schema(description = "전체 요소 수", example = "48") + /** + * 전체 요소 수입니다. + */ private long totalElements; - @Schema(description = "전체 페이지 수", example = "5") + /** + * 전체 페이지 수입니다. + */ private int totalPages; /** @@ -161,22 +180,31 @@ public static PageInfoResponse toDto(int page, int size, long totalElements) { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 목록 아이템 응답") public static class CommentItemResponse { - @Schema(description = "댓글 ID", example = "102") + /** + * 댓글 ID입니다. + */ private Long commentId; - @Schema(description = "부모 댓글 ID", example = "null") + /** + * 부모 댓글 ID입니다. + */ private Long parentCommentId; - @Schema(description = "댓글 내용", example = "두 번째 댓글입니다.") + /** + * 댓글 내용입니다. + */ private String commentContent; - @Schema(description = "작성자 정보") + /** + * 댓글 작성자 정보입니다. + */ private CommentAuthorResponse author; - @Schema(description = "댓글 작성 일시", example = "2025-10-14T14:30:00") + /** + * 댓글 작성 일시입니다. + */ private LocalDateTime createdAt; /** @@ -203,13 +231,16 @@ public static CommentItemResponse toDto(CommentListQueryResponse queryResponse) @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 작성자 응답") public static class CommentAuthorResponse { - @Schema(description = "작성자 ID", example = "uuid-user-1") + /** + * 작성자 ID입니다. + */ private String userId; - @Schema(description = "작성자 닉네임", example = "행복한강아지") + /** + * 작성자 닉네임입니다. + */ private String nickname; /** @@ -275,10 +306,11 @@ public static class CommentListQueryResponse { @Builder @AllArgsConstructor @NoArgsConstructor - @Schema(description = "댓글 단순 처리 응답") public static class CommentSimpleResponse { - @Schema(description = "응답 메시지", example = "댓글이 성공적으로 수정되었습니다.") + /** + * 응답 메시지입니다. + */ private String message; /** @@ -293,4 +325,157 @@ public static CommentSimpleResponse toDto(String message) { .build(); } } + + /** + * 내가 쓴 댓글 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyCommentListResponse { + + /** + * 응답 메시지입니다. + */ + private String message; + + /** + * 페이지 정보입니다. + */ + private PageInfoResponse pageInfo; + + /** + * 내가 쓴 댓글 목록입니다. + */ + private List data; + + /** + * MyBatis 조회 결과를 내가 쓴 댓글 목록 조회 응답 DTO로 변환합니다. + * + * @param queryResponses 내가 쓴 댓글 목록 조회 결과 + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 댓글 수 + * @param message 응답 메시지 + * @return 내가 쓴 댓글 목록 조회 응답 DTO + */ + public static MyCommentListResponse toDto( + List queryResponses, + int page, + int size, + long totalElements, + String message + ) { + List data = queryResponses == null + ? List.of() + : queryResponses.stream() + .map(MyCommentItemResponse::toDto) + .toList(); + + return MyCommentListResponse.builder() + .message(message) + .pageInfo(PageInfoResponse.toDto(page, size, totalElements)) + .data(data) + .build(); + } + } + + /** + * 내가 쓴 댓글 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyCommentItemResponse { + + /** + * 댓글 ID입니다. + */ + private Long commentId; + + /** + * 댓글이 작성된 게시글 ID입니다. + */ + private Long boardId; + + /** + * 댓글이 작성된 게시글 제목입니다. + */ + private String boardTitle; + + /** + * 부모 댓글 ID입니다. + */ + private Long parentCommentId; + + /** + * 댓글 내용입니다. + */ + private String commentContent; + + /** + * 댓글 작성 일시입니다. + */ + private LocalDateTime createdAt; + + /** + * 내가 쓴 댓글 목록 조회 결과를 응답 아이템 DTO로 변환합니다. + * + * @param queryResponse 내가 쓴 댓글 목록 조회 결과 + * @return 내가 쓴 댓글 목록 아이템 응답 DTO + */ + public static MyCommentItemResponse toDto(MyCommentListQueryResponse queryResponse) { + return MyCommentItemResponse.builder() + .commentId(queryResponse.getCommentId()) + .boardId(queryResponse.getBoardId()) + .boardTitle(queryResponse.getBoardTitle()) + .parentCommentId(queryResponse.getParentCommentId()) + .commentContent(queryResponse.getCommentContent()) + .createdAt(queryResponse.getCreatedAt()) + .build(); + } + } + + /** + * 내가 쓴 댓글 목록 조회 MyBatis 결과 DTO입니다. + */ + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyCommentListQueryResponse { + + /** + * 댓글 ID입니다. + */ + private Long commentId; + + /** + * 게시글 ID입니다. + */ + private Long boardId; + + /** + * 게시글 제목입니다. + */ + private String boardTitle; + + /** + * 부모 댓글 ID입니다. + */ + private Long parentCommentId; + + /** + * 댓글 내용입니다. + */ + private String commentContent; + + /** + * 댓글 작성 일시입니다. + */ + private LocalDateTime createdAt; + } } diff --git a/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java index b890dbe..97cfa51 100644 --- a/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java +++ b/src/main/java/com/dodo/backend/comment/mapper/CommentMapper.java @@ -1,10 +1,11 @@ package com.dodo.backend.comment.mapper; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListQueryResponse; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; - import java.util.List; +import java.util.UUID; /** * 댓글(Comment) 도메인의 목록 조회 및 동적 수정 쿼리를 담당하는 MyBatis Mapper 인터페이스입니다. @@ -34,6 +35,28 @@ List findCommentsByBoardId( */ long countCommentsByBoardId(@Param("boardId") Long boardId); + /** + * 특정 사용자가 작성한 댓글 목록을 페이지 단위로 조회합니다. + * + * @param userId 조회할 사용자 ID + * @param offset 조회 시작 위치 + * @param size 조회 개수 + * @return 내가 쓴 댓글 목록 조회 결과 + */ + List findMyComments( + @Param("userId") UUID userId, + @Param("offset") int offset, + @Param("size") int size + ); + + /** + * 특정 사용자가 작성한 댓글 전체 개수를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 내가 쓴 댓글 전체 개수 + */ + long countMyComments(@Param("userId") UUID userId); + /** * 댓글 내용을 수정합니다. * diff --git a/src/main/java/com/dodo/backend/comment/service/CommentService.java b/src/main/java/com/dodo/backend/comment/service/CommentService.java index 5f01e58..fcd2423 100644 --- a/src/main/java/com/dodo/backend/comment/service/CommentService.java +++ b/src/main/java/com/dodo/backend/comment/service/CommentService.java @@ -5,6 +5,7 @@ import com.dodo.backend.comment.dto.response.CommentResponse.CommentCreateResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; import com.dodo.backend.comment.exception.CommentException; import java.util.UUID; @@ -34,6 +35,16 @@ public interface CommentService { */ CommentListResponse getComments(Long boardId, int page, int size); + /** + * 요청 사용자가 작성한 댓글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 댓글 목록 조회 응답 DTO + */ + MyCommentListResponse getMyComments(UUID userId, int page, int size); + /** * 특정 댓글을 수정합니다. * diff --git a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java index e4bb7b6..48b378b 100644 --- a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java @@ -9,6 +9,8 @@ import com.dodo.backend.comment.dto.response.CommentResponse.CommentListQueryResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentListResponse; import com.dodo.backend.comment.dto.response.CommentResponse.CommentSimpleResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.comment.exception.CommentErrorCode; import com.dodo.backend.comment.exception.CommentException; @@ -55,8 +57,8 @@ public class CommentServiceImpl implements CommentService { * @return 댓글 작성 응답 DTO * @throws CommentException 잘못된 요청 또는 부모 댓글이 없는 경우 */ - @Override @Transactional + @Override public CommentCreateResponse createComment(UUID userId, CommentCreateRequest request) { if (userId == null || request == null || isBlank(request.getCommentContent())) { throw new CommentException(INVALID_REQUEST); @@ -82,8 +84,8 @@ public CommentCreateResponse createComment(UUID userId, CommentCreateRequest req * @param size 페이지 크기 * @return 댓글 목록 조회 응답 DTO */ - @Override @Transactional(readOnly = true) + @Override public CommentListResponse getComments(Long boardId, int page, int size) { validateCommentListRequest(boardId, page, size); @@ -103,6 +105,35 @@ public CommentListResponse getComments(Long boardId, int page, int size) { ); } + /** + * 요청 사용자가 작성한 댓글 목록을 조회합니다. + * + * @param userId 요청 사용자 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 내가 쓴 댓글 목록 조회 응답 DTO + */ + @Transactional(readOnly = true) + @Override + public MyCommentListResponse getMyComments(UUID userId, int page, int size) { + if (userId == null) { + throw new CommentException(INVALID_REQUEST); + } + validateMyCommentListRequest(page, size); + + int offset = page * size; + List queryResponses = commentMapper.findMyComments(userId, offset, size); + long totalElements = commentMapper.countMyComments(userId); + + return MyCommentListResponse.toDto( + queryResponses, + page, + size, + totalElements, + "내가 쓴 댓글 목록을 성공적으로 조회했습니다." + ); + } + /** * 특정 댓글을 수정합니다. * @@ -112,8 +143,8 @@ public CommentListResponse getComments(Long boardId, int page, int size) { * @return 댓글 수정 응답 DTO * @throws CommentException 잘못된 요청, 댓글 없음, 수정 권한 없음인 경우 */ - @Override @Transactional + @Override public CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentUpdateRequest request) { if (userId == null || request == null || isBlank(request.getCommentContent())) { throw new CommentException(INVALID_REQUEST); @@ -137,8 +168,8 @@ public CommentSimpleResponse updateComment(UUID userId, Long commentId, CommentU * @return 댓글 삭제 응답 DTO * @throws CommentException 잘못된 요청, 댓글 없음, 삭제 권한 없음인 경우 */ - @Override @Transactional + @Override public CommentSimpleResponse deleteComment(UUID userId, Long commentId) { if (userId == null) { throw new CommentException(INVALID_REQUEST); @@ -211,6 +242,18 @@ private void validateCommentListRequest(Long boardId, int page, int size) { } } + /** + * 내가 쓴 댓글 목록 조회 요청 값을 검증합니다. + * + * @param page 페이지 번호 + * @param size 페이지 크기 + */ + private void validateMyCommentListRequest(int page, int size) { + if (page < 0 || size <= 0 || size > MAX_COMMENT_LIST_SIZE || page > Integer.MAX_VALUE / size) { + throw new CommentException(INVALID_REQUEST); + } + } + /** * 댓글 작성자인지 검증합니다. * diff --git a/src/main/java/com/dodo/backend/reaction/repository/ReactionRepository.java b/src/main/java/com/dodo/backend/reaction/repository/ReactionRepository.java index 31fc138..a9762df 100644 --- a/src/main/java/com/dodo/backend/reaction/repository/ReactionRepository.java +++ b/src/main/java/com/dodo/backend/reaction/repository/ReactionRepository.java @@ -57,6 +57,15 @@ public interface ReactionRepository extends JpaRepository { */ Optional findByUser_UsersIdAndBoard_BoardId(UUID userId, Long boardId); + /** + * 특정 게시글에 남겨진 지정 반응 유형의 개수를 조회합니다. + * + * @param boardId 반응 대상 게시글 ID + * @param reactionType 조회할 반응 유형 + * @return 지정 반응 유형의 개수 + */ + long countByBoard_BoardIdAndReactionType(Long boardId, ReactionType reactionType); + /** * 특정 사용자가 지정한 활동 기록 목록에 남긴 반응을 조회합니다. */ diff --git a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml index bfce201..6176c37 100644 --- a/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml +++ b/src/main/resources/com/dodo/backend/board/mapper/BoardMapper.xml @@ -3,54 +3,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - - - - UPDATE board - - board_title = #{request.boardTitle}, - board_content = #{request.boardContent}, - modified_at = NOW() - - WHERE board_id = #{boardId} - - - - - UPDATE board - SET view_count = view_count + 1 - WHERE board_id = #{boardId} - - - - - UPDATE board - SET board_status = #{status}, - board_status_updated_at = NOW() - WHERE board_id = #{boardId} - - - - - + + + + + + + UPDATE board + + board_title = #{request.boardTitle}, + board_content = #{request.boardContent}, + modified_at = NOW() + + WHERE board_id = #{boardId} + + + + UPDATE board + SET view_count = view_count + 1 + WHERE board_id = #{boardId} + + + + UPDATE board + SET board_status = #{status}, + board_status_updated_at = NOW() + WHERE board_id = #{boardId} + diff --git a/src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml b/src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml new file mode 100644 index 0000000..9c9367c --- /dev/null +++ b/src/main/resources/com/dodo/backend/comment/mapper/CommentMapper.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + UPDATE comment + SET comment_content = #{commentContent}, + comment_modified_at = NOW() + WHERE comment_id = #{commentId} + + diff --git a/src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java b/src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java new file mode 100644 index 0000000..dc25c71 --- /dev/null +++ b/src/test/java/com/dodo/backend/board/controller/BoardMyBoardControllerTest.java @@ -0,0 +1,88 @@ +package com.dodo.backend.board.controller; + +import com.dodo.backend.board.dto.response.BoardResponse.BoardListItemResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; +import com.dodo.backend.board.service.BoardService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 게시글 목록 조회 컨트롤러 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class BoardMyBoardControllerTest { + + @Mock + private BoardService boardService; + + /** + * 내가 쓴 게시글 목록 조회 요청 시 200 상태 코드와 목록 응답을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 게시글 목록 조회 성공: 200 상태 코드와 목록을 반환한다.") + void getMyBoards_Success() { + BoardController boardController = new BoardController(boardService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + BoardListResponse serviceResponse = createBoardListResponse(); + + given(boardService.getMyBoards(userId, 0, 10)).willReturn(serviceResponse); + + ResponseEntity response = boardController.getMyBoards(0, 10, userDetails); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().getBoards().size()); + assertEquals(1L, response.getBody().getBoards().get(0).getBoardId()); + + verify(boardService).getMyBoards(userId, 0, 10); + } + + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } + + private BoardListResponse createBoardListResponse() { + BoardListItemResponse itemResponse = BoardListItemResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContentPreview("오늘 산책하다가 찍은 사진이에요. 너") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(12L) + .dislikeCount(1L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + return BoardListResponse.builder() + .message("내가 쓴 게시글 목록을 성공적으로 조회했습니다.") + .boards(List.of(itemResponse)) + .totalPages(1) + .totalElements(1L) + .currentPage(0) + .pageSize(10) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java new file mode 100644 index 0000000..f2eea78 --- /dev/null +++ b/src/test/java/com/dodo/backend/board/service/BoardMyBoardServiceTest.java @@ -0,0 +1,103 @@ +package com.dodo.backend.board.service; + +import com.dodo.backend.board.dto.response.BoardResponse.BoardListQueryResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardListResponse; +import com.dodo.backend.board.exception.BoardErrorCode; +import com.dodo.backend.board.exception.BoardException; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +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.redis.core.RedisTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 게시글 목록 조회 비즈니스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class BoardMyBoardServiceTest { + + @InjectMocks + private BoardServiceImpl boardService; + + @Mock + private BoardRepository boardRepository; + + @Mock + private UserService userService; + + @Mock + private ImageFileService imageFileService; + + @Mock + private BoardMapper boardMapper; + + @Mock + private RedisTemplate redisTemplate; + + /** + * 내가 쓴 게시글 목록 조회 시 페이지 정보와 게시글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 게시글 목록 조회 성공: 게시글 목록과 페이지 정보를 반환한다.") + void getMyBoards_Success() { + UUID userId = UUID.randomUUID(); + BoardListQueryResponse queryResponse = BoardListQueryResponse.builder() + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .boardContent("오늘 산책하다가 찍은 사진이에요. 너무 귀엽죠?") + .thumbnailImageUrl("https://example.com/images/bori_1.jpg") + .nickname("자유로운산책") + .viewCount(51) + .commentCount(3L) + .likeCount(12L) + .dislikeCount(1L) + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .modifiedAt(LocalDateTime.of(2026, 1, 31, 14, 10, 12)) + .build(); + + given(boardMapper.findMyBoardList(userId, 0, 10)).willReturn(List.of(queryResponse)); + given(boardMapper.countMyBoards(userId)).willReturn(1L); + + BoardListResponse response = boardService.getMyBoards(userId, 0, 10); + + assertNotNull(response); + assertEquals("내가 쓴 게시글 목록을 성공적으로 조회했습니다.", response.getMessage()); + assertEquals(1, response.getBoards().size()); + assertEquals(1L, response.getBoards().get(0).getBoardId()); + assertEquals(1L, response.getTotalElements()); + assertEquals(1, response.getTotalPages()); + + verify(boardMapper).findMyBoardList(userId, 0, 10); + verify(boardMapper).countMyBoards(userId); + } + + /** + * 사용자 ID가 없으면 잘못된 요청 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 게시글 목록 조회 실패: 사용자 ID가 없으면 예외가 발생한다.") + void getMyBoards_Fail_UserIdNull() { + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getMyBoards(null, 0, 10)); + + assertEquals(BoardErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(boardMapper, never()).findMyBoardList(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } +} diff --git a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java index a6ed2ea..84126ba 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -17,6 +17,8 @@ import com.dodo.backend.board.mapper.BoardMapper; import com.dodo.backend.board.repository.BoardRepository; import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.reaction.entity.ReactionType; +import com.dodo.backend.reaction.repository.ReactionRepository; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.extern.slf4j.Slf4j; @@ -71,6 +73,9 @@ class BoardServiceTest { @Mock private BoardMapper boardMapper; + @Mock + private ReactionRepository reactionRepository; + @Mock private RedisTemplate redisTemplate; @@ -233,6 +238,8 @@ void getBoardDetail_Success() { "https://example.com/images/bori_1.jpg", "https://example.com/images/bori_2.jpg" )); + given(reactionRepository.countByBoard_BoardIdAndReactionType(boardId, ReactionType.LIKE)).willReturn(12L); + given(reactionRepository.countByBoard_BoardIdAndReactionType(boardId, ReactionType.DISLIKE)).willReturn(1L); // when BoardDetailResponse response = boardService.getBoardDetail(userId, boardId); @@ -246,6 +253,8 @@ void getBoardDetail_Success() { assertEquals("자유로운영혼", response.getNickname()); assertEquals("https://example.com/profiles/writer.jpg", response.getProfileUrl()); assertEquals(51, response.getViewCount()); + assertEquals(12L, response.getLikeCount()); + assertEquals(1L, response.getDislikeCount()); assertEquals(createdAt, response.getBoardCreatedAt()); assertEquals(2, response.getImageFileUrls().size()); assertEquals("https://example.com/images/bori_1.jpg", response.getImageFileUrls().get(0)); @@ -253,6 +262,8 @@ void getBoardDetail_Success() { verify(boardRepository).findById(boardId); verify(imageFileService).getBoardImageUrls(boardId); + verify(reactionRepository).countByBoard_BoardIdAndReactionType(boardId, ReactionType.LIKE); + verify(reactionRepository).countByBoard_BoardIdAndReactionType(boardId, ReactionType.DISLIKE); verify(boardMapper, never()).increaseViewCount(boardId); log.info("테스트 종료: 게시글 상세 조회 성공 검증 완료"); } @@ -287,6 +298,8 @@ void getBoardDetail_Success_IncreaseViewCount() { given(imageFileService.getBoardImageUrls(boardId)).willReturn(List.of( "https://example.com/images/bori_1.jpg" )); + given(reactionRepository.countByBoard_BoardIdAndReactionType(boardId, ReactionType.LIKE)).willReturn(5L); + given(reactionRepository.countByBoard_BoardIdAndReactionType(boardId, ReactionType.DISLIKE)).willReturn(2L); // when BoardDetailResponse response = boardService.getBoardDetail(requestUserId, boardId); @@ -294,6 +307,8 @@ void getBoardDetail_Success_IncreaseViewCount() { // then assertNotNull(response); assertEquals(52, response.getViewCount()); + assertEquals(5L, response.getLikeCount()); + assertEquals(2L, response.getDislikeCount()); verify(boardRepository).findById(boardId); verify(boardMapper).increaseViewCount(boardId); verify(imageFileService).getBoardImageUrls(boardId); diff --git a/src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java b/src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java new file mode 100644 index 0000000..30f3691 --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/controller/CommentMyCommentControllerTest.java @@ -0,0 +1,82 @@ +package com.dodo.backend.comment.controller; + +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentItemResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.PageInfoResponse; +import com.dodo.backend.comment.service.CommentService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 댓글 목록 조회 컨트롤러 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class CommentMyCommentControllerTest { + + @Mock + private CommentService commentService; + + /** + * 내가 쓴 댓글 목록 조회 요청 시 200 상태 코드와 목록 응답을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 댓글 목록 조회 성공: 200 상태 코드와 목록을 반환한다.") + void getMyComments_Success() { + CommentController commentController = new CommentController(commentService); + UUID userId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(userId); + MyCommentListResponse serviceResponse = createMyCommentListResponse(); + + given(commentService.getMyComments(userId, 0, 10)).willReturn(serviceResponse); + + ResponseEntity response = commentController.getMyComments(0, 10, userDetails); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().getData().size()); + assertEquals(10L, response.getBody().getData().get(0).getCommentId()); + assertEquals(1L, response.getBody().getData().get(0).getBoardId()); + + verify(commentService).getMyComments(userId, 0, 10); + } + + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } + + private MyCommentListResponse createMyCommentListResponse() { + MyCommentItemResponse itemResponse = MyCommentItemResponse.builder() + .commentId(10L) + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .parentCommentId(null) + .commentContent("좋은 정보 감사합니다.") + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .build(); + + return MyCommentListResponse.builder() + .message("내가 쓴 댓글 목록을 성공적으로 조회했습니다.") + .pageInfo(PageInfoResponse.toDto(0, 10, 1L)) + .data(List.of(itemResponse)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java b/src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java new file mode 100644 index 0000000..ba38f0a --- /dev/null +++ b/src/test/java/com/dodo/backend/comment/service/CommentMyCommentServiceTest.java @@ -0,0 +1,94 @@ +package com.dodo.backend.comment.service; + +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListQueryResponse; +import com.dodo.backend.comment.dto.response.CommentResponse.MyCommentListResponse; +import com.dodo.backend.comment.exception.CommentErrorCode; +import com.dodo.backend.comment.exception.CommentException; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 내가 쓴 댓글 목록 조회 비즈니스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class CommentMyCommentServiceTest { + + @InjectMocks + private CommentServiceImpl commentService; + + @Mock + private CommentRepository commentRepository; + + @Mock + private CommentMapper commentMapper; + + @Mock + private BoardService boardService; + + @Mock + private UserService userService; + + /** + * 내가 쓴 댓글 목록 조회 시 게시글 정보가 포함된 댓글 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 댓글 목록 조회 성공: 게시글 정보가 포함된 댓글 목록을 반환한다.") + void getMyComments_Success() { + UUID userId = UUID.randomUUID(); + MyCommentListQueryResponse queryResponse = MyCommentListQueryResponse.builder() + .commentId(10L) + .boardId(1L) + .boardTitle("우리 강아지 자랑합니다") + .parentCommentId(null) + .commentContent("좋은 정보 감사합니다.") + .createdAt(LocalDateTime.of(2026, 1, 31, 13, 52, 32)) + .build(); + + given(commentMapper.findMyComments(userId, 0, 10)).willReturn(List.of(queryResponse)); + given(commentMapper.countMyComments(userId)).willReturn(1L); + + MyCommentListResponse response = commentService.getMyComments(userId, 0, 10); + + assertNotNull(response); + assertEquals("내가 쓴 댓글 목록을 성공적으로 조회했습니다.", response.getMessage()); + assertEquals(1, response.getData().size()); + assertEquals(10L, response.getData().get(0).getCommentId()); + assertEquals(1L, response.getData().get(0).getBoardId()); + assertEquals("우리 강아지 자랑합니다", response.getData().get(0).getBoardTitle()); + + verify(commentMapper).findMyComments(userId, 0, 10); + verify(commentMapper).countMyComments(userId); + } + + /** + * 사용자 ID가 없으면 잘못된 요청 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("내가 쓴 댓글 목록 조회 실패: 사용자 ID가 없으면 예외가 발생한다.") + void getMyComments_Fail_UserIdNull() { + CommentException exception = assertThrows(CommentException.class, + () -> commentService.getMyComments(null, 0, 10)); + + assertEquals(CommentErrorCode.INVALID_REQUEST, exception.getErrorCode()); + verify(commentMapper, never()).findMyComments(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } +} From 13608809ff65ba77d9262d7e8ec1fd9030775e4b Mon Sep 17 00:00:00 2001 From: limhb708 Date: Mon, 15 Jun 2026 09:56:48 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EB=B0=8F=20=EB=8C=93=EA=B8=80=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReportRequest DTO 추가 및 신고 사유 정의 - 게시글/댓글 신고 로직 및 대상 존재 검증 구현 - 중복 신고 방지 로직 적용 - POST /reports/boards/{boardId}, /reports/comments/{commentId} API 구현 - 신고 데이터 저장 처리 - Service/Controller 테스트 코드 작성 --- .../exception/GlobalExceptionHandler.java | 10 + .../report/controller/ReportController.java | 150 ++++++++++++ .../report/dto/request/ReportRequest.java | 29 +++ .../report/dto/response/ReportResponse.java | 40 +++ .../dodo/backend/report/entity/Report.java | 81 ++++++ .../backend/report/entity/ReportReason.java | 51 ++++ .../backend/report/entity/ReportStatus.java | 16 ++ .../report/exception/ReportErrorCode.java | 54 ++++ .../report/exception/ReportException.java | 22 ++ .../report/repository/ReportRepository.java | 42 ++++ .../backend/report/service/ReportService.java | 46 ++++ .../report/service/ReportServiceImpl.java | 231 ++++++++++++++++++ .../controller/ReportControllerTest.java | 153 ++++++++++++ .../report/service/ReportServiceTest.java | 180 ++++++++++++++ 14 files changed, 1105 insertions(+) create mode 100644 src/main/java/com/dodo/backend/report/controller/ReportController.java create mode 100644 src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java create mode 100644 src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java create mode 100644 src/main/java/com/dodo/backend/report/entity/Report.java create mode 100644 src/main/java/com/dodo/backend/report/entity/ReportReason.java create mode 100644 src/main/java/com/dodo/backend/report/entity/ReportStatus.java create mode 100644 src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java create mode 100644 src/main/java/com/dodo/backend/report/exception/ReportException.java create mode 100644 src/main/java/com/dodo/backend/report/repository/ReportRepository.java create mode 100644 src/main/java/com/dodo/backend/report/service/ReportService.java create mode 100644 src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java create mode 100644 src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java create mode 100644 src/test/java/com/dodo/backend/report/service/ReportServiceTest.java diff --git a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java index 6a5a998..d6ff619 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import com.dodo.backend.pet.exception.PetException; import com.dodo.backend.petweight.exception.PetWeightException; import com.dodo.backend.reaction.exception.ReactionException; +import com.dodo.backend.report.exception.ReportException; import com.dodo.backend.user.exception.UserErrorCode; import com.dodo.backend.user.exception.UserException; import com.dodo.backend.userpet.exception.UserPetException; @@ -129,6 +130,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 신고(Report) 도메인 비즈니스 로직에서 발생하는 {@link ReportException}을 처리합니다. + */ + @ExceptionHandler(ReportException.class) + protected ResponseEntity handleReportException(ReportException e) { + log.error("ReportException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * 이미지 파일(ImageFile) 도메인 비즈니스 로직에서 발생하는 {@link ImageFileException}을 처리합니다. */ diff --git a/src/main/java/com/dodo/backend/report/controller/ReportController.java b/src/main/java/com/dodo/backend/report/controller/ReportController.java new file mode 100644 index 0000000..2110764 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/controller/ReportController.java @@ -0,0 +1,150 @@ +package com.dodo.backend.report.controller; + +import com.dodo.backend.common.exception.ErrorResponse; +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.service.ReportService; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +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.RestController; + +import java.util.UUID; + +/** + * 신고 API 요청을 처리하는 컨트롤러입니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/reports") +@Tag(name = "Report API", description = "신고 관련 API") +@Slf4j +public class ReportController { + + private final ReportService reportService; + + /** + * 특정 게시글을 신고합니다. + * + * @param boardId 신고 대상 게시글 ID + * @param request 신고 요청 DTO + * @param userDetails 인증 사용자 정보 + * @return 신고 처리 응답 + */ + @Operation(summary = "게시글 신고", description = "인증 사용자가 특정 게시글을 신고합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "신고할 게시글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 신고한 게시글입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/board/{boardId}") + public ResponseEntity reportBoard( + @PathVariable Long boardId, + @Valid @RequestBody ReportCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID reporterId = UUID.fromString(userDetails.getUsername()); + log.info("게시글 신고 요청 - Reporter: {}, BoardId: {}", reporterId, boardId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(reportService.reportBoard(reporterId, boardId, request)); + } + + /** + * 특정 유저를 신고합니다. + * + * @param userId 신고 대상 유저 ID + * @param request 신고 요청 DTO + * @param userDetails 인증 사용자 정보 + * @return 신고 처리 응답 + */ + @Operation(summary = "유저 신고", description = "인증 사용자가 특정 유저를 신고합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "신고할 유저를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 신고한 게시글입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/user/{userId}") + public ResponseEntity reportUser( + @PathVariable UUID userId, + @Valid @RequestBody ReportCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID reporterId = UUID.fromString(userDetails.getUsername()); + log.info("유저 신고 요청 - Reporter: {}, ReportedUser: {}", reporterId, userId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(reportService.reportUser(reporterId, userId, request)); + } + + /** + * 특정 댓글을 신고합니다. + * + * @param commentId 신고 대상 댓글 ID + * @param request 신고 요청 DTO + * @param userDetails 인증 사용자 정보 + * @return 신고 처리 응답 + */ + @Operation(summary = "댓글 신고", description = "인증 사용자가 특정 댓글을 신고합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "신고할 댓글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 신고한 게시글입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/comments/{commentId}") + public ResponseEntity reportComment( + @PathVariable Long commentId, + @Valid @RequestBody ReportCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID reporterId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 신고 요청 - Reporter: {}, CommentId: {}", reporterId, commentId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(reportService.reportComment(reporterId, commentId, request)); + } +} diff --git a/src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java b/src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java new file mode 100644 index 0000000..bef70f1 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java @@ -0,0 +1,29 @@ +package com.dodo.backend.report.dto.request; + +import com.dodo.backend.report.entity.ReportReason; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신고 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "신고 요청 DTO 그룹") +public class ReportRequest { + + /** + * 신고 생성 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "신고 생성 요청") + public static class ReportCreateRequest { + + @NotNull(message = "신고 사유는 필수입니다.") + @Schema(description = "신고 사유", example = "SPAM") + private ReportReason reportReason; + } +} diff --git a/src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java b/src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java new file mode 100644 index 0000000..07490d9 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java @@ -0,0 +1,40 @@ +package com.dodo.backend.report.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신고 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "신고 응답 DTO 그룹") +public class ReportResponse { + + /** + * 신고 처리 결과 메시지를 반환하는 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "신고 단순 처리 응답") + public static class ReportSimpleResponse { + + @Schema(description = "응답 메시지", example = "신고가 성공적으로 접수되었습니다.") + private String message; + + /** + * 신고 단순 처리 응답 DTO를 생성합니다. + * + * @param message 응답 메시지 + * @return 신고 단순 처리 응답 DTO + */ + public static ReportSimpleResponse toDto(String message) { + return ReportSimpleResponse.builder() + .message(message) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/report/entity/Report.java b/src/main/java/com/dodo/backend/report/entity/Report.java new file mode 100644 index 0000000..35f130c --- /dev/null +++ b/src/main/java/com/dodo/backend/report/entity/Report.java @@ -0,0 +1,81 @@ +package com.dodo.backend.report.entity; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 신고 정보를 관리하는 엔티티입니다. + *

+ * 신고자와 신고 대상(게시글, 유저, 댓글), 신고 사유, 처리 상태와 생성 일시를 저장합니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "report", + uniqueConstraints = { + @UniqueConstraint(name = "uk_report_reporter_board", columnNames = {"reporter_id", "board_id"}) + } +) +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long reportId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_user_id") + private User reportedUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + @Enumerated(EnumType.STRING) + @Column(name = "report_reason", nullable = false) + private ReportReason reportReason; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "report_status", nullable = false) + private ReportStatus reportStatus = ReportStatus.PENDING; + + @CreatedDate + @Column(name = "report_created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dodo/backend/report/entity/ReportReason.java b/src/main/java/com/dodo/backend/report/entity/ReportReason.java new file mode 100644 index 0000000..839ff37 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/entity/ReportReason.java @@ -0,0 +1,51 @@ +package com.dodo.backend.report.entity; + +/** + * 신고 사유를 정의하는 열거형입니다. + */ +public enum ReportReason { + /** + * 스팸 또는 광고성 게시물입니다. + */ + SPAM, + + /** + * 음란물 또는 선정적 콘텐츠입니다. + */ + OBSCENITY, + + /** + * 욕설, 비방, 혐오 발언입니다. + */ + ABUSE, + + /** + * 부적절한 프로필입니다. + */ + INAPPROPRIATE_PROFILE, + + /** + * 스팸 또는 광고 목적의 계정/댓글입니다. + */ + SPAM_ADVERTISING, + + /** + * 사칭 또는 명의 도용입니다. + */ + IMPERSONATION, + + /** + * 혐오 발언입니다. + */ + HATE_SPEECH, + + /** + * 개인정보 노출입니다. + */ + PRIVATE_INFO_EXPOSURE, + + /** + * 괴롭힘 또는 지속적인 공격입니다. + */ + HARASSMENT +} diff --git a/src/main/java/com/dodo/backend/report/entity/ReportStatus.java b/src/main/java/com/dodo/backend/report/entity/ReportStatus.java new file mode 100644 index 0000000..59be8c1 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/entity/ReportStatus.java @@ -0,0 +1,16 @@ +package com.dodo.backend.report.entity; + +/** + * 신고 처리 상태를 정의하는 열거형입니다. + */ +public enum ReportStatus { + /** + * 신고 접수 후 처리 대기 상태입니다. + */ + PENDING, + + /** + * 신고 처리가 완료된 상태입니다. + */ + COMPLETED +} diff --git a/src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java b/src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java new file mode 100644 index 0000000..dadb56a --- /dev/null +++ b/src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java @@ -0,0 +1,54 @@ +package com.dodo.backend.report.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 신고 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@AllArgsConstructor +@Getter +public enum ReportErrorCode implements BaseErrorCode { + + /** + * 잘못된 신고 요청인 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 신고할 게시글을 찾을 수 없는 경우 사용합니다. + */ + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "신고할 게시글을 찾을 수 없습니다."), + + /** + * 신고할 유저를 찾을 수 없는 경우 사용합니다. + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "신고할 유저를 찾을 수 없습니다."), + + /** + * 신고할 댓글을 찾을 수 없는 경우 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "신고할 댓글을 찾을 수 없습니다."), + + /** + * 이미 신고한 대상인 경우 사용합니다. + */ + REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 게시글입니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 에러 응답 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/report/exception/ReportException.java b/src/main/java/com/dodo/backend/report/exception/ReportException.java new file mode 100644 index 0000000..188d742 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/exception/ReportException.java @@ -0,0 +1,22 @@ +package com.dodo.backend.report.exception; + +import lombok.Getter; + +/** + * 신고 도메인 비즈니스 로직에서 발생하는 예외입니다. + */ +@Getter +public class ReportException extends RuntimeException { + + private final ReportErrorCode errorCode; + + /** + * 신고 도메인 예외를 생성합니다. + * + * @param errorCode 신고 에러 코드 + */ + public ReportException(ReportErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java new file mode 100644 index 0000000..e84d098 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java @@ -0,0 +1,42 @@ +package com.dodo.backend.report.repository; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * {@link Report} 엔티티의 데이터베이스 접근을 담당하는 repository입니다. + */ +@Repository +public interface ReportRepository extends JpaRepository { + + /** + * 특정 유저가 특정 게시글을 이미 신고했는지 확인합니다. + * + * @param reporter 신고자 + * @param board 신고 대상 게시글 + * @return 이미 신고했으면 true + */ + boolean existsByReporterAndBoard(User reporter, Board board); + + /** + * 특정 유저가 특정 유저를 이미 신고했는지 확인합니다. + * + * @param reporter 신고자 + * @param reportedUser 신고 대상 유저 + * @return 이미 신고했으면 true + */ + boolean existsByReporterAndReportedUser(User reporter, User reportedUser); + + /** + * 특정 유저가 특정 댓글을 이미 신고했는지 확인합니다. + * + * @param reporter 신고자 + * @param comment 신고 대상 댓글 + * @return 이미 신고했으면 true + */ + boolean existsByReporterAndComment(User reporter, Comment comment); +} diff --git a/src/main/java/com/dodo/backend/report/service/ReportService.java b/src/main/java/com/dodo/backend/report/service/ReportService.java new file mode 100644 index 0000000..628ef26 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/service/ReportService.java @@ -0,0 +1,46 @@ +package com.dodo.backend.report.service; + +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.exception.ReportException; + +import java.util.UUID; + +/** + * 신고 도메인의 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface ReportService { + + /** + * 특정 게시글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param boardId 신고 대상 게시글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + * @throws ReportException 잘못된 요청, 게시글 없음, 중복 신고인 경우 + */ + ReportSimpleResponse reportBoard(UUID reporterId, Long boardId, ReportCreateRequest request); + + /** + * 특정 유저를 신고합니다. + * + * @param reporterId 신고자 ID + * @param userId 신고 대상 유저 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + * @throws ReportException 잘못된 요청, 유저 없음, 중복 신고인 경우 + */ + ReportSimpleResponse reportUser(UUID reporterId, UUID userId, ReportCreateRequest request); + + /** + * 특정 댓글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param commentId 신고 대상 댓글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + * @throws ReportException 잘못된 요청, 댓글 없음, 중복 신고인 경우 + */ + ReportSimpleResponse reportComment(UUID reporterId, Long commentId, ReportCreateRequest request); +} diff --git a/src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java b/src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java new file mode 100644 index 0000000..a72a0cc --- /dev/null +++ b/src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java @@ -0,0 +1,231 @@ +package com.dodo.backend.report.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.exception.BoardException; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.exception.ReportException; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.exception.UserException; +import com.dodo.backend.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; + +import static com.dodo.backend.report.entity.ReportReason.ABUSE; +import static com.dodo.backend.report.entity.ReportReason.IMPERSONATION; +import static com.dodo.backend.report.entity.ReportReason.INAPPROPRIATE_PROFILE; +import static com.dodo.backend.report.entity.ReportReason.OBSCENITY; +import static com.dodo.backend.report.entity.ReportReason.SPAM; +import static com.dodo.backend.report.entity.ReportReason.SPAM_ADVERTISING; +import static com.dodo.backend.report.exception.ReportErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.report.exception.ReportErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.report.exception.ReportErrorCode.INVALID_REQUEST; +import static com.dodo.backend.report.exception.ReportErrorCode.REPORT_ALREADY_EXISTS; +import static com.dodo.backend.report.exception.ReportErrorCode.USER_NOT_FOUND; + +/** + * {@link ReportService} 구현체입니다. + *

+ * 게시글, 유저, 댓글 신고 생성과 중복 신고 검증을 처리합니다. + */ +@Service +@RequiredArgsConstructor +public class ReportServiceImpl implements ReportService { + + private static final String REPORT_SUCCESS_MESSAGE = "신고가 성공적으로 접수되었습니다."; + private static final Set BOARD_REPORT_REASONS = EnumSet.of(SPAM, OBSCENITY, ABUSE); + private static final Set USER_REPORT_REASONS = EnumSet.of(INAPPROPRIATE_PROFILE, SPAM_ADVERTISING, IMPERSONATION); + private static final Set COMMENT_REPORT_REASONS = EnumSet.of(SPAM_ADVERTISING, ABUSE, OBSCENITY); + + private final ReportRepository reportRepository; + private final BoardService boardService; + private final UserService userService; + private final CommentRepository commentRepository; + + /** + * 특정 게시글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param boardId 신고 대상 게시글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + */ + @Transactional + @Override + public ReportSimpleResponse reportBoard(UUID reporterId, Long boardId, ReportCreateRequest request) { + validateRequest(reporterId, request); + validateId(boardId); + validateReason(request.getReportReason(), BOARD_REPORT_REASONS); + + User reporter = findReporter(reporterId); + Board board = findBoard(boardId); + + if (reportRepository.existsByReporterAndBoard(reporter, board)) { + throw new ReportException(REPORT_ALREADY_EXISTS); + } + + Report report = Report.builder() + .reporter(reporter) + .board(board) + .reportReason(request.getReportReason()) + .build(); + + reportRepository.save(report); + return ReportSimpleResponse.toDto(REPORT_SUCCESS_MESSAGE); + } + + /** + * 특정 유저를 신고합니다. + * + * @param reporterId 신고자 ID + * @param userId 신고 대상 유저 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + */ + @Transactional + @Override + public ReportSimpleResponse reportUser(UUID reporterId, UUID userId, ReportCreateRequest request) { + validateRequest(reporterId, request); + if (userId == null) { + throw new ReportException(INVALID_REQUEST); + } + validateReason(request.getReportReason(), USER_REPORT_REASONS); + + User reporter = findReporter(reporterId); + User reportedUser = findReportedUser(userId); + + if (reportRepository.existsByReporterAndReportedUser(reporter, reportedUser)) { + throw new ReportException(REPORT_ALREADY_EXISTS); + } + + Report report = Report.builder() + .reporter(reporter) + .reportedUser(reportedUser) + .reportReason(request.getReportReason()) + .build(); + + reportRepository.save(report); + return ReportSimpleResponse.toDto(REPORT_SUCCESS_MESSAGE); + } + + /** + * 특정 댓글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param commentId 신고 대상 댓글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + */ + @Transactional + @Override + public ReportSimpleResponse reportComment(UUID reporterId, Long commentId, ReportCreateRequest request) { + validateRequest(reporterId, request); + validateId(commentId); + validateReason(request.getReportReason(), COMMENT_REPORT_REASONS); + + User reporter = findReporter(reporterId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new ReportException(COMMENT_NOT_FOUND)); + + if (reportRepository.existsByReporterAndComment(reporter, comment)) { + throw new ReportException(REPORT_ALREADY_EXISTS); + } + + Report report = Report.builder() + .reporter(reporter) + .comment(comment) + .reportReason(request.getReportReason()) + .build(); + + reportRepository.save(report); + return ReportSimpleResponse.toDto(REPORT_SUCCESS_MESSAGE); + } + + /** + * 신고 요청의 공통 필수 값을 검증합니다. + * + * @param reporterId 신고자 ID + * @param request 신고 요청 DTO + */ + private void validateRequest(UUID reporterId, ReportCreateRequest request) { + if (reporterId == null || request == null || request.getReportReason() == null) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 숫자 ID 요청 값을 검증합니다. + * + * @param id 검증할 ID + */ + private void validateId(Long id) { + if (id == null || id <= 0) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 대상 유형에서 허용하는 신고 사유인지 검증합니다. + * + * @param reportReason 요청 신고 사유 + * @param allowedReasons 허용 신고 사유 목록 + */ + private void validateReason(ReportReason reportReason, Set allowedReasons) { + if (!allowedReasons.contains(reportReason)) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 신고자 유저를 조회합니다. + * + * @param reporterId 신고자 ID + * @return 신고자 유저 엔티티 + */ + private User findReporter(UUID reporterId) { + try { + return userService.getUserById(reporterId); + } catch (UserException e) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 신고 대상 게시글을 조회합니다. + * + * @param boardId 게시글 ID + * @return 게시글 엔티티 + */ + private Board findBoard(Long boardId) { + try { + return boardService.getBoardById(boardId); + } catch (BoardException e) { + throw new ReportException(BOARD_NOT_FOUND); + } + } + + /** + * 신고 대상 유저를 조회합니다. + * + * @param userId 신고 대상 유저 ID + * @return 신고 대상 유저 엔티티 + */ + private User findReportedUser(UUID userId) { + try { + return userService.getUserById(userId); + } catch (UserException e) { + throw new ReportException(USER_NOT_FOUND); + } + } +} diff --git a/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java b/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java new file mode 100644 index 0000000..e1c029a --- /dev/null +++ b/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java @@ -0,0 +1,153 @@ +package com.dodo.backend.report.controller; + +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.service.ReportService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 신고 컨트롤러 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class ReportControllerTest { + + @Mock + private ReportService reportService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + ReportController reportController = new ReportController(reportService); + mockMvc = MockMvcBuilders.standaloneSetup(reportController) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + objectMapper = new ObjectMapper(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + /** + * 게시글 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + void reportBoard_Success() throws Exception { + UUID reporterId = UUID.randomUUID(); + Long boardId = 1L; + UserDetails userDetails = createUserDetails(reporterId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM); + ReportSimpleResponse serviceResponse = ReportSimpleResponse.toDto("신고가 성공적으로 접수되었습니다."); + setAuthentication(userDetails); + + given(reportService.reportBoard(eq(reporterId), eq(boardId), any(ReportCreateRequest.class))) + .willReturn(serviceResponse); + + mockMvc.perform(post("/reports/board/{boardId}", boardId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); + + verify(reportService).reportBoard(eq(reporterId), eq(boardId), any(ReportCreateRequest.class)); + } + + /** + * 유저 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("유저 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + void reportUser_Success() throws Exception { + UUID reporterId = UUID.randomUUID(); + UUID reportedUserId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(reporterId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + ReportSimpleResponse serviceResponse = ReportSimpleResponse.toDto("신고가 성공적으로 접수되었습니다."); + setAuthentication(userDetails); + + given(reportService.reportUser(eq(reporterId), eq(reportedUserId), any(ReportCreateRequest.class))) + .willReturn(serviceResponse); + + mockMvc.perform(post("/reports/user/{userId}", reportedUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); + + verify(reportService).reportUser(eq(reporterId), eq(reportedUserId), any(ReportCreateRequest.class)); + } + + /** + * 댓글 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + void reportComment_Success() throws Exception { + UUID reporterId = UUID.randomUUID(); + Long commentId = 10L; + UserDetails userDetails = createUserDetails(reporterId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM_ADVERTISING); + ReportSimpleResponse serviceResponse = ReportSimpleResponse.toDto("신고가 성공적으로 접수되었습니다."); + setAuthentication(userDetails); + + given(reportService.reportComment(eq(reporterId), eq(commentId), any(ReportCreateRequest.class))) + .willReturn(serviceResponse); + + mockMvc.perform(post("/reports/comments/{commentId}", commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); + + verify(reportService).reportComment(eq(reporterId), eq(commentId), any(ReportCreateRequest.class)); + } + + private void setAuthentication(UserDetails userDetails) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UsernamePasswordAuthenticationToken( + userDetails, + userDetails.getPassword(), + userDetails.getAuthorities() + )); + SecurityContextHolder.setContext(context); + } + + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/report/service/ReportServiceTest.java b/src/test/java/com/dodo/backend/report/service/ReportServiceTest.java new file mode 100644 index 0000000..803e6e1 --- /dev/null +++ b/src/test/java/com/dodo/backend/report/service/ReportServiceTest.java @@ -0,0 +1,180 @@ +package com.dodo.backend.report.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.exception.ReportException; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +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 java.util.Optional; +import java.util.UUID; + +import static com.dodo.backend.report.exception.ReportErrorCode.INVALID_REQUEST; +import static com.dodo.backend.report.exception.ReportErrorCode.REPORT_ALREADY_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 신고 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private BoardService boardService; + + @Mock + private UserService userService; + + @Mock + private CommentRepository commentRepository; + + @InjectMocks + private ReportServiceImpl reportService; + + /** + * 게시글 신고 성공 시 신고를 저장하고 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 성공: 신고를 저장하고 성공 메시지를 반환한다.") + void reportBoard_Success() { + UUID reporterId = UUID.randomUUID(); + Long boardId = 1L; + User reporter = createUser(reporterId); + Board board = createBoard(boardId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(boardService.getBoardById(boardId)).willReturn(board); + given(reportRepository.existsByReporterAndBoard(reporter, board)).willReturn(false); + + ReportSimpleResponse response = reportService.reportBoard(reporterId, boardId, request); + + assertNotNull(response); + assertEquals("신고가 성공적으로 접수되었습니다.", response.getMessage()); + verify(reportRepository).save(any()); + } + + /** + * 이미 신고한 게시글이면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 실패: 이미 신고한 게시글이면 예외가 발생한다.") + void reportBoard_Fail_AlreadyExists() { + UUID reporterId = UUID.randomUUID(); + Long boardId = 1L; + User reporter = createUser(reporterId); + Board board = createBoard(boardId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(boardService.getBoardById(boardId)).willReturn(board); + given(reportRepository.existsByReporterAndBoard(reporter, board)).willReturn(true); + + ReportException exception = assertThrows(ReportException.class, + () -> reportService.reportBoard(reporterId, boardId, request)); + + assertEquals(REPORT_ALREADY_EXISTS, exception.getErrorCode()); + verify(reportRepository, never()).save(any()); + } + + /** + * 게시글 신고에서 허용되지 않는 신고 사유면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 실패: 게시글에 허용되지 않는 신고 사유면 예외가 발생한다.") + void reportBoard_Fail_InvalidReason() { + UUID reporterId = UUID.randomUUID(); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + + ReportException exception = assertThrows(ReportException.class, + () -> reportService.reportBoard(reporterId, 1L, request)); + + assertEquals(INVALID_REQUEST, exception.getErrorCode()); + verify(reportRepository, never()).save(any()); + } + + /** + * 유저 신고 성공 시 신고를 저장하고 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("유저 신고 성공: 신고를 저장하고 성공 메시지를 반환한다.") + void reportUser_Success() { + UUID reporterId = UUID.randomUUID(); + UUID reportedUserId = UUID.randomUUID(); + User reporter = createUser(reporterId); + User reportedUser = createUser(reportedUserId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(userService.getUserById(reportedUserId)).willReturn(reportedUser); + given(reportRepository.existsByReporterAndReportedUser(reporter, reportedUser)).willReturn(false); + + ReportSimpleResponse response = reportService.reportUser(reporterId, reportedUserId, request); + + assertNotNull(response); + assertEquals("신고가 성공적으로 접수되었습니다.", response.getMessage()); + verify(reportRepository).save(any()); + } + + /** + * 댓글 신고 성공 시 신고를 저장하고 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 신고 성공: 신고를 저장하고 성공 메시지를 반환한다.") + void reportComment_Success() { + UUID reporterId = UUID.randomUUID(); + Long commentId = 10L; + User reporter = createUser(reporterId); + Comment comment = createComment(commentId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM_ADVERTISING); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + given(reportRepository.existsByReporterAndComment(reporter, comment)).willReturn(false); + + ReportSimpleResponse response = reportService.reportComment(reporterId, commentId, request); + + assertNotNull(response); + assertEquals("신고가 성공적으로 접수되었습니다.", response.getMessage()); + verify(reportRepository).save(any()); + } + + private User createUser(UUID userId) { + return User.builder() + .usersId(userId) + .build(); + } + + private Board createBoard(Long boardId) { + return Board.builder() + .boardId(boardId) + .build(); + } + + private Comment createComment(Long commentId) { + return Comment.builder() + .commentId(commentId) + .build(); + } +} From f51c1c380daaf60de31f0c33ca192c40e576b26c Mon Sep 17 00:00:00 2001 From: limhb708 Date: Tue, 16 Jun 2026 20:18:00 +0900 Subject: [PATCH 11/12] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=8B=A0=EA=B3=A0=20=EB=B0=8F=20=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 신고 상세/목록 조회 API 구현 - 유저/게시글/댓글 관리 상태 변경 및 삭제 기능 추가 - 공지 작성/수정/삭제/목록/상세 조회 API 구현 - 관리자 권한 설정 및 예외 처리 추가 - 신고/공지 Repository 조회 메서드 추가 - Service/Controller 테스트 코드 작성 - 한글 인코딩 및 API 응답 메시지 정리 --- .../admin/controller/AdminController.java | 210 ++++++++ .../admin/dto/request/AdminRequest.java | 92 ++++ .../admin/dto/response/AdminResponse.java | 326 +++++++++++++ .../backend/admin/entity/AdminReportType.java | 10 + .../admin/exception/AdminErrorCode.java | 74 +++ .../admin/exception/AdminException.java | 23 + .../backend/admin/service/AdminService.java | 50 ++ .../admin/service/AdminServiceImpl.java | 454 ++++++++++++++++++ .../com/dodo/backend/board/entity/Board.java | 26 +- .../board/repository/BoardRepository.java | 21 + .../backend/common/config/SecurityConfig.java | 1 + .../exception/GlobalExceptionHandler.java | 10 + .../dodo/backend/report/entity/Report.java | 9 + .../report/repository/ReportRepository.java | 90 ++++ .../admin/controller/AdminControllerTest.java | 120 +++++ .../admin/service/AdminServiceTest.java | 175 +++++++ .../backend/main/service/MainServiceTest.java | 12 +- 17 files changed, 1696 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/dodo/backend/admin/controller/AdminController.java create mode 100644 src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java create mode 100644 src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java create mode 100644 src/main/java/com/dodo/backend/admin/entity/AdminReportType.java create mode 100644 src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java create mode 100644 src/main/java/com/dodo/backend/admin/exception/AdminException.java create mode 100644 src/main/java/com/dodo/backend/admin/service/AdminService.java create mode 100644 src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java create mode 100644 src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java create mode 100644 src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java diff --git a/src/main/java/com/dodo/backend/admin/controller/AdminController.java b/src/main/java/com/dodo/backend/admin/controller/AdminController.java new file mode 100644 index 0000000..aad64d7 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/controller/AdminController.java @@ -0,0 +1,210 @@ +package com.dodo.backend.admin.controller; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.report.entity.ReportStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +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.util.UUID; + +/** + * 관리자 API 요청을 처리하는 컨트롤러입니다. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin") +@Tag(name = "Admin API", description = "관리자 기능 API") +public class AdminController { + + private final AdminService adminService; + + /** + * 특정 게시글의 신고 상세 내역을 조회합니다. + */ + @Operation(summary = "게시글 신고 상세 조회", description = "특정 게시글에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/board/{boardId}") + public ResponseEntity getBoardReportDetail(@PathVariable Long boardId) { + log.info("관리자 게시글 신고 상세 조회 요청 - BoardId: {}", boardId); + return ResponseEntity.ok(adminService.getBoardReportDetail(boardId)); + } + + /** + * 특정 유저의 신고 상세 내역을 조회합니다. + */ + @Operation(summary = "유저 신고 상세 조회", description = "특정 유저에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/user/{userId}") + public ResponseEntity getUserReportDetail(@PathVariable UUID userId) { + log.info("관리자 유저 신고 상세 조회 요청 - UserId: {}", userId); + return ResponseEntity.ok(adminService.getUserReportDetail(userId)); + } + + /** + * 특정 댓글의 신고 상세 내역을 조회합니다. + */ + @Operation(summary = "댓글 신고 상세 조회", description = "특정 댓글에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/comment/{commentId}") + public ResponseEntity getCommentReportDetail(@PathVariable Long commentId) { + log.info("관리자 댓글 신고 상세 조회 요청 - CommentId: {}", commentId); + return ResponseEntity.ok(adminService.getCommentReportDetail(commentId)); + } + + /** + * 신고 목록을 조회합니다. + */ + @Operation(summary = "신고 목록 조회", description = "신고 유형과 처리 상태에 따라 신고 목록을 조회합니다.") + @GetMapping("/reports") + public ResponseEntity getReportList( + @RequestParam AdminReportType reportType, + @RequestParam(required = false) ReportStatus reportStatus, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort + ) { + log.info("관리자 신고 목록 조회 요청 - Type: {}, Status: {}, Page: {}, Size: {}, Sort: {}", + reportType, reportStatus, page, size, sort); + return ResponseEntity.ok(adminService.getReportList(reportType, reportStatus, page, size, sort)); + } + + /** + * 유저 계정 상태를 변경합니다. + */ + @Operation(summary = "유저 계정 상태 변경", description = "관리자가 유저 계정 상태를 변경합니다.") + @PatchMapping("/users/{userId}/status") + public ResponseEntity updateUserStatus( + @PathVariable UUID userId, + @Valid @RequestBody UserStatusUpdateRequest request + ) { + log.info("관리자 유저 상태 변경 요청 - UserId: {}, Status: {}", userId, request.getStatus()); + return ResponseEntity.ok(adminService.updateUserStatus(userId, request)); + } + + /** + * 게시글을 강제로 삭제합니다. + */ + @Operation(summary = "게시글 강제 삭제", description = "관리자가 게시글을 삭제 상태로 변경합니다.") + @DeleteMapping("/boards/{boardId}") + public ResponseEntity deleteBoard(@PathVariable Long boardId) { + log.info("관리자 게시글 강제 삭제 요청 - BoardId: {}", boardId); + adminService.deleteBoard(boardId); + return ResponseEntity.noContent().build(); + } + + /** + * 댓글을 강제로 삭제합니다. + */ + @Operation(summary = "댓글 강제 삭제", description = "관리자가 댓글을 강제로 삭제합니다.") + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId) { + log.info("관리자 댓글 강제 삭제 요청 - CommentId: {}", commentId); + adminService.deleteComment(commentId); + return ResponseEntity.noContent().build(); + } + + /** + * 신고 처리 상태를 변경합니다. + */ + @Operation(summary = "신고 처리 상태 변경", description = "관리자가 신고 처리 상태를 변경합니다.") + @PatchMapping("/reports/{reportId}/status") + public ResponseEntity updateReportStatus( + @PathVariable Long reportId, + @Valid @RequestBody ReportStatusUpdateRequest request + ) { + log.info("관리자 신고 상태 변경 요청 - ReportId: {}, Status: {}", reportId, request.getStatus()); + return ResponseEntity.ok(adminService.updateReportStatus(reportId, request)); + } + + /** + * 공지를 작성합니다. + */ + @Operation(summary = "공지 작성", description = "관리자가 공지를 작성합니다.") + @PostMapping("/announcements") + public ResponseEntity createAnnouncement( + @Valid @RequestBody AnnouncementCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID adminId = UUID.fromString(userDetails.getUsername()); + log.info("관리자 공지 작성 요청 - AdminId: {}", adminId); + return ResponseEntity + .status(HttpStatus.OK) + .body(adminService.createAnnouncement(adminId, request)); + } + + /** + * 공지를 삭제합니다. + */ + @Operation(summary = "공지 삭제", description = "관리자가 공지를 삭제합니다.") + @DeleteMapping("/announcements/{boardId}") + public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { + log.info("관리자 공지 삭제 요청 - BoardId: {}", boardId); + adminService.deleteAnnouncement(boardId); + return ResponseEntity.noContent().build(); + } + + /** + * 공지를 수정합니다. + */ + @Operation(summary = "공지 수정", description = "관리자가 공지를 수정합니다.") + @PatchMapping("/announcements/{boardId}") + public ResponseEntity updateAnnouncement( + @PathVariable Long boardId, + @RequestBody AnnouncementUpdateRequest request + ) { + log.info("관리자 공지 수정 요청 - BoardId: {}", boardId); + adminService.updateAnnouncement(boardId, request); + return ResponseEntity.noContent().build(); + } + + /** + * 공지 목록을 조회합니다. + */ + @Operation(summary = "공지 목록 조회", description = "공지 목록을 페이지 단위로 조회합니다.") + @GetMapping("/announcements") + public ResponseEntity getAnnouncementList( + @ParameterObject @PageableDefault(size = 10) Pageable pageable + ) { + log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}", pageable.getPageNumber(), pageable.getPageSize()); + return ResponseEntity.ok(adminService.getAnnouncementList(pageable)); + } + + /** + * 공지 상세를 조회합니다. + */ + @Operation(summary = "공지 상세 조회", description = "공지 상세 내용을 조회합니다.") + @GetMapping("/announcements/{boardId}") + public ResponseEntity getAnnouncementDetail(@PathVariable Long boardId) { + log.info("관리자 공지 상세 조회 요청 - BoardId: {}", boardId); + return ResponseEntity.ok(adminService.getAnnouncementDetail(boardId)); + } +} diff --git a/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java b/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java new file mode 100644 index 0000000..fc1bb31 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java @@ -0,0 +1,92 @@ +package com.dodo.backend.admin.dto.request; + +import com.dodo.backend.board.entity.NoticeTag; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.user.entity.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 관리자 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "관리자 요청 DTO 그룹") +public class AdminRequest { + + /** + * 유저 계정 상태 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "유저 계정 상태 변경 요청") + public static class UserStatusUpdateRequest { + + @NotNull(message = "상태 값은 필수입니다.") + @Schema(description = "변경할 유저 상태", example = "SUSPENDED") + private UserStatus status; + } + + /** + * 신고 처리 상태 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "신고 처리 상태 변경 요청") + public static class ReportStatusUpdateRequest { + + @NotNull(message = "상태 값은 필수입니다.") + @Schema(description = "변경할 신고 처리 상태", example = "COMPLETED") + private ReportStatus status; + } + + /** + * 공지 작성 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "공지 작성 요청") + public static class AnnouncementCreateRequest { + + @NotBlank(message = "공지 제목은 필수입니다.") + @Schema(description = "공지 제목", example = "공지 제목") + private String boardTitle; + + @NotBlank(message = "공지 내용은 필수입니다.") + @Schema(description = "공지 내용", example = "공지 내용입니다.") + private String boardContent; + + @Schema(description = "공지 이미지 URL", example = "https://example.com/images/notice.jpg") + private String imageFileUrl; + + @Schema(description = "조회 수", example = "10") + private Integer viewCount; + + @Schema(description = "공지 태그", example = "INFO") + private NoticeTag noticeTag; + } + + /** + * 공지 수정 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "공지 수정 요청") + public static class AnnouncementUpdateRequest { + + @Schema(description = "수정할 공지 제목", example = "수정된 공지입니다.") + private String boardTitle; + + @Schema(description = "수정할 공지 내용", example = "수정수정수정") + private String boardContent; + + @Schema(description = "수정할 공지 이미지 URL", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + } +} diff --git a/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java new file mode 100644 index 0000000..d8435bb --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java @@ -0,0 +1,326 @@ +package com.dodo.backend.admin.dto.response; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "관리자 응답 DTO 그룹") +public class AdminResponse { + + /** + * 단순 메시지 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "관리자 단순 응답") + public static class AdminSimpleResponse { + private String message; + + public static AdminSimpleResponse toDto(String message) { + return AdminSimpleResponse.builder() + .message(message) + .build(); + } + } + + /** + * 페이지 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "페이지 정보") + public static class PageInfoResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + + public static PageInfoResponse toDto(int page, int size, long totalElements) { + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + return PageInfoResponse.builder() + .page(page) + .size(size) + .totalElements(totalElements) + .totalPages(totalPages) + .build(); + } + + public static PageInfoResponse toDto(Page page) { + return PageInfoResponse.builder() + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + } + + /** + * 유저 요약 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "유저 요약 정보") + public static class UserInfoResponse { + private String userId; + private String nickname; + + public static UserInfoResponse toDto(User user) { + if (user == null) { + return null; + } + return UserInfoResponse.builder() + .userId(user.getUsersId() == null ? null : user.getUsersId().toString()) + .nickname(user.getNickname()) + .build(); + } + } + + /** + * 신고 게시글 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 게시글 정보") + public static class BoardInfoResponse { + private String boardTitle; + private String boardContent; + private LocalDateTime boardCreatedAt; + + public static BoardInfoResponse toDto(Board board) { + return BoardInfoResponse.builder() + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .boardCreatedAt(board.getBoardCreatedAt()) + .build(); + } + } + + /** + * 신고 상세 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 상세 아이템") + public static class ReportDetailItemResponse { + private Long reportId; + private UserInfoResponse reporterInfo; + private ReportReason reportReason; + private ReportStatus reportStatus; + private LocalDateTime reportCreatedAt; + + public static ReportDetailItemResponse toDto(Report report) { + return ReportDetailItemResponse.builder() + .reportId(report.getReportId()) + .reporterInfo(UserInfoResponse.toDto(report.getReporter())) + .reportReason(report.getReportReason()) + .reportStatus(report.getReportStatus()) + .reportCreatedAt(report.getCreatedAt()) + .build(); + } + } + + /** + * 게시글 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "게시글 신고 상세 응답") + public static class BoardReportDetailResponse { + private Long boardId; + private BoardInfoResponse boardInfo; + private UserInfoResponse reportedUserInfo; + private long totalReportCount; + private List reports; + } + + /** + * 유저 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "유저 신고 상세 응답") + public static class UserReportDetailResponse { + private UserInfoResponse reportedUserInfo; + private long totalReportCount; + private List reports; + } + + /** + * 댓글 신고 상세 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 신고 상세 아이템") + public static class CommentReportItemResponse { + private Long reportId; + private String reporterNickname; + private ReportReason reportReason; + private LocalDateTime reportCreatedAt; + + public static CommentReportItemResponse toDto(Report report) { + return CommentReportItemResponse.builder() + .reportId(report.getReportId()) + .reporterNickname(report.getReporter() == null ? null : report.getReporter().getNickname()) + .reportReason(report.getReportReason()) + .reportCreatedAt(report.getCreatedAt()) + .build(); + } + } + + /** + * 댓글 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 신고 상세 응답") + public static class CommentReportDetailResponse { + private Long commentId; + private String commentContent; + private String reportedNickname; + private long totalReportCount; + private List reports; + + public static CommentReportDetailResponse toDto(Comment comment, List reports) { + return CommentReportDetailResponse.builder() + .commentId(comment.getCommentId()) + .commentContent(comment.getCommentContent()) + .reportedNickname(comment.getUser() == null ? null : comment.getUser().getNickname()) + .totalReportCount(reports.size()) + .reports(reports.stream().map(CommentReportItemResponse::toDto).toList()) + .build(); + } + } + + /** + * 신고 목록 대상 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 대상 정보") + public static class ReportTargetInfoResponse { + private Object id; + private String summary; + } + + /** + * 신고 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 아이템") + public static class ReportListItemResponse { + private String reportType; + private ReportTargetInfoResponse targetInfo; + private UserInfoResponse reportedUser; + private long totalReportCount; + private ReportReason representativeReason; + private ReportStatus reportStatus; + private LocalDateTime lastReportedAt; + } + + /** + * 신고 목록 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 응답") + public static class ReportListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 공지 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 목록 아이템") + public static class AnnouncementItemResponse { + private Long boardId; + private String boardTitle; + private String boardContent; + private String imageFileUrl; + private Integer viewCount; + private LocalDateTime boardCreatedAt; + + public static AnnouncementItemResponse toDto(Board board, String imageFileUrl) { + return AnnouncementItemResponse.builder() + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .build(); + } + } + + /** + * 공지 목록 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 목록 응답") + public static class AnnouncementListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 공지 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 상세 응답") + public static class AnnouncementDetailResponse { + private Long boardId; + private String boardTitle; + private String boardContent; + private String imageFileUrl; + private Integer viewCount; + private LocalDateTime boardCreatedAt; + private LocalDateTime boardModifiedAt; + + public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl) { + return AnnouncementDetailResponse.builder() + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .boardModifiedAt(board.getModifiedAt()) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java b/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java new file mode 100644 index 0000000..a47e65b --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java @@ -0,0 +1,10 @@ +package com.dodo.backend.admin.entity; + +/** + * 관리자 신고 목록에서 조회할 신고 대상 유형입니다. + */ +public enum AdminReportType { + BOARD, + USER, + COMMENT +} diff --git a/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java b/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java new file mode 100644 index 0000000..d74b694 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java @@ -0,0 +1,74 @@ +package com.dodo.backend.admin.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 관리자 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@Getter +@AllArgsConstructor +public enum AdminErrorCode implements BaseErrorCode { + + /** + * 요청 값이 올바르지 않은 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 신고당한 게시글이 존재하지 않는 경우 사용합니다. + */ + REPORTED_BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 게시글이 존재하지 않습니다."), + + /** + * 신고당한 유저가 존재하지 않는 경우 사용합니다. + */ + REPORTED_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 유저가 존재하지 않습니다."), + + /** + * 신고당한 댓글이 존재하지 않는 경우 사용합니다. + */ + REPORTED_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 댓글이 존재하지 않습니다."), + + /** + * 유저가 존재하지 않는 경우 사용합니다. + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + /** + * 게시글이 존재하지 않는 경우 사용합니다. + */ + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + + /** + * 댓글이 존재하지 않는 경우 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + + /** + * 신고 내역이 존재하지 않는 경우 사용합니다. + */ + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고 내역입니다."), + + /** + * 공지가 존재하지 않는 경우 사용합니다. + */ + ANNOUNCEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 공지입니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 에러 응답 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/admin/exception/AdminException.java b/src/main/java/com/dodo/backend/admin/exception/AdminException.java new file mode 100644 index 0000000..9d290a2 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/exception/AdminException.java @@ -0,0 +1,23 @@ +package com.dodo.backend.admin.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.Getter; + +/** + * 관리자 도메인 비즈니스 로직에서 발생하는 예외입니다. + */ +@Getter +public class AdminException extends RuntimeException { + + private final BaseErrorCode errorCode; + + /** + * 관리자 도메인 예외를 생성합니다. + * + * @param errorCode 에러 코드 + */ + public AdminException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/admin/service/AdminService.java b/src/main/java/com/dodo/backend/admin/service/AdminService.java new file mode 100644 index 0000000..75f13dc --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/service/AdminService.java @@ -0,0 +1,50 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.report.entity.ReportStatus; +import org.springframework.data.domain.Pageable; + +import java.util.UUID; + +/** + * 관리자 API 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface AdminService { + + BoardReportDetailResponse getBoardReportDetail(Long boardId); + + UserReportDetailResponse getUserReportDetail(UUID userId); + + CommentReportDetailResponse getCommentReportDetail(Long commentId); + + ReportListResponse getReportList(AdminReportType reportType, ReportStatus reportStatus, int page, int size, String sort); + + AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest request); + + void deleteBoard(Long boardId); + + void deleteComment(Long commentId); + + AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateRequest request); + + AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRequest request); + + void deleteAnnouncement(Long boardId); + + void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request); + + AnnouncementListResponse getAnnouncementList(Pageable pageable); + + AnnouncementDetailResponse getAnnouncementDetail(Long boardId); +} diff --git a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java new file mode 100644 index 0000000..04cdb79 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java @@ -0,0 +1,454 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportDetailItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportTargetInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.admin.exception.AdminException; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.mapper.UserMapper; +import com.dodo.backend.user.repository.UserRepository; +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; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.dodo.backend.admin.exception.AdminErrorCode.ANNOUNCEMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.INVALID_REQUEST; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_BOARD_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_COMMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_USER_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.USER_NOT_FOUND; + +/** + * {@link AdminService} 구현체입니다. + */ +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private static final int MAX_PAGE_SIZE = 100; + private static final String STATUS_UPDATE_SUCCESS_MESSAGE = "성공적으로 상태를 변경했습니다."; + + private final ReportRepository reportRepository; + private final BoardRepository boardRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final UserMapper userMapper; + private final BoardMapper boardMapper; + private final ImageFileService imageFileService; + + /** + * 특정 게시글의 신고 상세 내역을 조회합니다. + */ + @Transactional(readOnly = true) + @Override + public BoardReportDetailResponse getBoardReportDetail(Long boardId) { + validatePositiveId(boardId); + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new AdminException(REPORTED_BOARD_NOT_FOUND)); + List reports = reportRepository.findAllByBoard_BoardIdOrderByCreatedAtDesc(boardId); + + return BoardReportDetailResponse.builder() + .boardId(board.getBoardId()) + .boardInfo(BoardInfoResponse.toDto(board)) + .reportedUserInfo(UserInfoResponse.toDto(board.getUser())) + .totalReportCount(reports.size()) + .reports(reports.stream().map(ReportDetailItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 유저의 신고 상세 내역을 조회합니다. + */ + @Transactional(readOnly = true) + @Override + public UserReportDetailResponse getUserReportDetail(UUID userId) { + if (userId == null) { + throw new AdminException(INVALID_REQUEST); + } + + User reportedUser = userRepository.findById(userId) + .orElseThrow(() -> new AdminException(REPORTED_USER_NOT_FOUND)); + List reports = reportRepository.findAllByReportedUser_UsersIdOrderByCreatedAtDesc(userId); + + return UserReportDetailResponse.builder() + .reportedUserInfo(UserInfoResponse.toDto(reportedUser)) + .totalReportCount(reports.size()) + .reports(reports.stream().map(ReportDetailItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 댓글의 신고 상세 내역을 조회합니다. + */ + @Transactional(readOnly = true) + @Override + public CommentReportDetailResponse getCommentReportDetail(Long commentId) { + validatePositiveId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new AdminException(REPORTED_COMMENT_NOT_FOUND)); + List reports = reportRepository.findAllByComment_CommentIdOrderByCreatedAtDesc(commentId); + + return CommentReportDetailResponse.toDto(comment, reports); + } + + /** + * 신고 유형별 신고 목록을 조회합니다. + */ + @Transactional(readOnly = true) + @Override + public ReportListResponse getReportList(AdminReportType reportType, ReportStatus reportStatus, int page, int size, String sort) { + validatePageRequest(reportType, page, size); + + List reports = findReports(reportType, reportStatus); + List groupedItems = groupReports(reportType, reports); + groupedItems.sort(buildReportListComparator(sort)); + + int fromIndex = Math.min(page * size, groupedItems.size()); + int toIndex = Math.min(fromIndex + size, groupedItems.size()); + List pageItems = groupedItems.subList(fromIndex, toIndex); + + return ReportListResponse.builder() + .pageInfo(PageInfoResponse.toDto(page, size, groupedItems.size())) + .data(pageItems) + .build(); + } + + /** + * 유저 계정 상태를 변경합니다. + */ + @Transactional + @Override + public AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest request) { + if (userId == null || request == null || request.getStatus() == null) { + throw new AdminException(INVALID_REQUEST); + } + if (!userRepository.existsById(userId)) { + throw new AdminException(USER_NOT_FOUND); + } + + userMapper.updateUserStatus(userId, request.getStatus().name()); + return AdminSimpleResponse.toDto(STATUS_UPDATE_SUCCESS_MESSAGE); + } + + /** + * 게시글을 강제로 삭제합니다. + */ + @Transactional + @Override + public void deleteBoard(Long boardId) { + validatePositiveId(boardId); + if (!boardRepository.existsById(boardId)) { + throw new AdminException(BOARD_NOT_FOUND); + } + + boardMapper.deleteBoard(boardId, BoardStatus.DELETED.name()); + imageFileService.deleteBoardImages(boardId); + } + + /** + * 댓글을 강제로 삭제합니다. + */ + @Transactional + @Override + public void deleteComment(Long commentId) { + validatePositiveId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new AdminException(COMMENT_NOT_FOUND)); + + reportRepository.deleteAllByComment(comment); + commentRepository.delete(comment); + } + + /** + * 신고 처리 상태를 변경합니다. + */ + @Transactional + @Override + public AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateRequest request) { + validatePositiveId(reportId); + if (request == null || request.getStatus() == null) { + throw new AdminException(INVALID_REQUEST); + } + + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new AdminException(REPORT_NOT_FOUND)); + report.updateReportStatus(request.getStatus()); + + return AdminSimpleResponse.toDto(STATUS_UPDATE_SUCCESS_MESSAGE); + } + + /** + * 공지를 작성합니다. + */ + @Transactional + @Override + public AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRequest request) { + validateAnnouncementCreateRequest(adminId, request); + + User admin = userRepository.findById(adminId) + .orElseThrow(() -> new AdminException(USER_NOT_FOUND)); + Board board = Board.builder() + .user(admin) + .boardTitle(request.getBoardTitle()) + .boardContent(request.getBoardContent()) + .viewCount(request.getViewCount() == null ? 0 : request.getViewCount()) + .boardStatus(BoardStatus.PUBLISHED) + .boardStatusUpdatedAt(LocalDateTime.now()) + .boardType(BoardType.NOTICE) + .noticeTag(request.getNoticeTag()) + .build(); + + Board savedBoard = boardRepository.save(board); + imageFileService.saveBoardImages(savedBoard, toImageList(request.getImageFileUrl())); + + return AdminSimpleResponse.toDto("공지가 성공적으로 작성되었습니다."); + } + + /** + * 공지를 삭제합니다. + */ + @Transactional + @Override + public void deleteAnnouncement(Long boardId) { + Board board = findAnnouncement(boardId); + board.updateBoardStatus(BoardStatus.DELETED); + imageFileService.deleteBoardImages(boardId); + } + + /** + * 공지를 수정합니다. + */ + @Transactional + @Override + public void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request) { + validateAnnouncementUpdateRequest(request); + + Board board = findAnnouncement(boardId); + board.updateAnnouncement(request.getBoardTitle(), request.getBoardContent()); + if (request.getImageFileUrl() != null) { + imageFileService.replaceBoardImages(board, toImageList(request.getImageFileUrl())); + } + } + + /** + * 공지 목록을 조회합니다. + */ + @Transactional(readOnly = true) + @Override + public AnnouncementListResponse getAnnouncementList(Pageable pageable) { + if (pageable == null || pageable.getPageNumber() < 0 || pageable.getPageSize() <= 0 || pageable.getPageSize() > MAX_PAGE_SIZE) { + throw new AdminException(INVALID_REQUEST); + } + + Page page = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable); + List items = page.getContent().stream() + .map(board -> AnnouncementItemResponse.toDto(board, firstImageUrl(board.getBoardId()))) + .toList(); + + return AnnouncementListResponse.builder() + .pageInfo(PageInfoResponse.toDto(page)) + .data(items) + .build(); + } + + /** + * 공지 상세를 조회합니다. + */ + @Transactional(readOnly = true) + @Override + public AnnouncementDetailResponse getAnnouncementDetail(Long boardId) { + Board board = findAnnouncement(boardId); + return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId)); + } + + private List findReports(AdminReportType reportType, ReportStatus reportStatus) { + return switch (reportType) { + case BOARD -> reportStatus == null + ? reportRepository.findAllByBoardIsNotNull() + : reportRepository.findAllByBoardIsNotNullAndReportStatus(reportStatus); + case USER -> reportStatus == null + ? reportRepository.findAllByReportedUserIsNotNull() + : reportRepository.findAllByReportedUserIsNotNullAndReportStatus(reportStatus); + case COMMENT -> reportStatus == null + ? reportRepository.findAllByCommentIsNotNull() + : reportRepository.findAllByCommentIsNotNullAndReportStatus(reportStatus); + }; + } + + private List groupReports(AdminReportType reportType, List reports) { + Function classifier = switch (reportType) { + case BOARD -> report -> report.getBoard().getBoardId(); + case USER -> report -> report.getReportedUser().getUsersId(); + case COMMENT -> report -> report.getComment().getCommentId(); + }; + + return reports.stream() + .collect(Collectors.groupingBy(classifier)) + .values() + .stream() + .map(group -> toReportListItem(reportType, group)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + private ReportListItemResponse toReportListItem(AdminReportType reportType, List reports) { + List sortedReports = new ArrayList<>(reports); + sortedReports.sort(Comparator.comparing(Report::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()); + Report latestReport = sortedReports.get(0); + + return ReportListItemResponse.builder() + .reportType(reportType.name()) + .targetInfo(buildTargetInfo(reportType, latestReport)) + .reportedUser(buildReportedUserInfo(reportType, latestReport)) + .totalReportCount(sortedReports.size()) + .representativeReason(latestReport.getReportReason()) + .reportStatus(resolveAggregateStatus(sortedReports)) + .lastReportedAt(latestReport.getCreatedAt()) + .build(); + } + + private ReportTargetInfoResponse buildTargetInfo(AdminReportType reportType, Report report) { + return switch (reportType) { + case BOARD -> ReportTargetInfoResponse.builder() + .id(report.getBoard().getBoardId()) + .summary(report.getBoard().getBoardTitle()) + .build(); + case USER -> ReportTargetInfoResponse.builder() + .id(report.getReportedUser().getUsersId().toString()) + .summary("사용자 닉네임: " + report.getReportedUser().getNickname()) + .build(); + case COMMENT -> ReportTargetInfoResponse.builder() + .id(report.getComment().getCommentId()) + .summary(report.getComment().getCommentContent()) + .build(); + }; + } + + private UserInfoResponse buildReportedUserInfo(AdminReportType reportType, Report report) { + return switch (reportType) { + case BOARD -> UserInfoResponse.toDto(report.getBoard().getUser()); + case USER -> UserInfoResponse.toDto(report.getReportedUser()); + case COMMENT -> UserInfoResponse.toDto(report.getComment().getUser()); + }; + } + + private ReportStatus resolveAggregateStatus(List reports) { + return reports.stream().anyMatch(report -> report.getReportStatus() == ReportStatus.PENDING) + ? ReportStatus.PENDING + : ReportStatus.COMPLETED; + } + + private Comparator buildReportListComparator(String sort) { + String normalized = sort == null || sort.isBlank() ? "lastReportedAt,desc" : sort; + String[] tokens = normalized.split(","); + String property = tokens[0].trim(); + boolean descending = tokens.length < 2 || !"asc".equalsIgnoreCase(tokens[1].trim()); + + Comparator comparator = switch (property) { + case "totalReportCount" -> Comparator.comparing(ReportListItemResponse::getTotalReportCount); + case "lastReportedAt" -> Comparator.comparing(ReportListItemResponse::getLastReportedAt, Comparator.nullsLast(Comparator.naturalOrder())); + default -> throw new AdminException(INVALID_REQUEST); + }; + + return descending ? comparator.reversed() : comparator; + } + + private Board findAnnouncement(Long boardId) { + validatePositiveId(boardId); + return boardRepository.findByBoardIdAndBoardType(boardId, BoardType.NOTICE) + .filter(board -> board.getBoardStatus() != BoardStatus.DELETED) + .orElseThrow(() -> new AdminException(ANNOUNCEMENT_NOT_FOUND)); + } + + private String firstImageUrl(Long boardId) { + List imageUrls = imageFileService.getBoardImageUrls(boardId); + return imageUrls.isEmpty() ? null : imageUrls.get(0); + } + + private List toImageList(String imageFileUrl) { + if (imageFileUrl == null || imageFileUrl.isBlank()) { + return List.of(); + } + return List.of(imageFileUrl); + } + + private void validateAnnouncementCreateRequest(UUID adminId, AnnouncementCreateRequest request) { + if (adminId == null || request == null || isBlank(request.getBoardTitle()) || isBlank(request.getBoardContent())) { + throw new AdminException(INVALID_REQUEST); + } + if (request.getViewCount() != null && request.getViewCount() < 0) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validateAnnouncementUpdateRequest(AnnouncementUpdateRequest request) { + if (request == null) { + throw new AdminException(INVALID_REQUEST); + } + boolean hasTitle = request.getBoardTitle() != null; + boolean hasContent = request.getBoardContent() != null; + boolean hasImage = request.getImageFileUrl() != null; + + if (!hasTitle && !hasContent && !hasImage) { + throw new AdminException(INVALID_REQUEST); + } + if ((hasTitle && request.getBoardTitle().isBlank()) || (hasContent && request.getBoardContent().isBlank())) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validatePageRequest(AdminReportType reportType, int page, int size) { + if (reportType == null || page < 0 || size <= 0 || size > MAX_PAGE_SIZE || page > Integer.MAX_VALUE / size) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validatePositiveId(Long id) { + if (id == null || id <= 0) { + throw new AdminException(INVALID_REQUEST); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/dodo/backend/board/entity/Board.java b/src/main/java/com/dodo/backend/board/entity/Board.java index 0bb4ad2..0a2471a 100644 --- a/src/main/java/com/dodo/backend/board/entity/Board.java +++ b/src/main/java/com/dodo/backend/board/entity/Board.java @@ -13,7 +13,7 @@ /** * 게시글(Board) 정보를 관리하는 엔티티입니다. *

- * 데이터베이스 {@code board} 테이블과 매핑되며, 작성자, 제목/본문, 조회수, + * 데이터베이스 {@code board} 테이블과 매핑되며, 작성자, 제목/본문, 조회 수, * 게시글 상태 및 게시판 유형 정보를 포함합니다. */ @Entity @@ -68,4 +68,28 @@ public class Board { @Column(name = "notice_tag") private NoticeTag noticeTag; + /** + * 공지 게시글 내용을 수정합니다. + * + * @param boardTitle 수정할 공지 제목 + * @param boardContent 수정할 공지 내용 + */ + public void updateAnnouncement(String boardTitle, String boardContent) { + if (boardTitle != null) { + this.boardTitle = boardTitle; + } + if (boardContent != null) { + this.boardContent = boardContent; + } + } + + /** + * 게시글 상태를 변경합니다. + * + * @param boardStatus 변경할 게시글 상태 + */ + public void updateBoardStatus(BoardStatus boardStatus) { + this.boardStatus = boardStatus; + this.boardStatusUpdatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/dodo/backend/board/repository/BoardRepository.java b/src/main/java/com/dodo/backend/board/repository/BoardRepository.java index bd858f6..62b9c03 100644 --- a/src/main/java/com/dodo/backend/board/repository/BoardRepository.java +++ b/src/main/java/com/dodo/backend/board/repository/BoardRepository.java @@ -3,11 +3,13 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; /** * {@link Board} 엔티티의 데이터베이스 접근을 담당하는 리포지토리 인터페이스입니다. @@ -28,4 +30,23 @@ List findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardStatus boardStatus, Pageable pageable ); + + /** + * 게시글 유형과 상태에 맞는 게시글 목록을 페이지 단위로 조회합니다. + * + * @param boardType 조회할 게시글 유형 + * @param boardStatus 조회할 게시글 상태 + * @param pageable 페이지 및 정렬 정보 + * @return 조건에 맞는 게시글 페이지 + */ + Page findAllByBoardTypeAndBoardStatus(BoardType boardType, BoardStatus boardStatus, Pageable pageable); + + /** + * 게시글 ID와 게시글 유형으로 게시글을 조회합니다. + * + * @param boardId 게시글 ID + * @param boardType 게시글 유형 + * @return 조회된 게시글 + */ + Optional findByBoardIdAndBoardType(Long boardId, BoardType boardType); } diff --git a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java index 41f93df..6406957 100644 --- a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java @@ -84,6 +84,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui.html", "/swagger-ui/**" ).denyAll() + .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(exception -> exception diff --git a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java index d6ff619..be9aff3 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.dodo.backend.common.exception; import com.dodo.backend.activityhistory.exception.ActivityHistoryException; +import com.dodo.backend.admin.exception.AdminException; import com.dodo.backend.auth.exception.AuthException; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.fence.exception.FenceException; @@ -130,6 +131,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 관리자(Admin) 도메인 비즈니스 로직에서 발생하는 {@link AdminException}을 처리합니다. + */ + @ExceptionHandler(AdminException.class) + protected ResponseEntity handleAdminException(AdminException e) { + log.error("AdminException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * 신고(Report) 도메인 비즈니스 로직에서 발생하는 {@link ReportException}을 처리합니다. */ diff --git a/src/main/java/com/dodo/backend/report/entity/Report.java b/src/main/java/com/dodo/backend/report/entity/Report.java index 6d60e98..95b4a2e 100644 --- a/src/main/java/com/dodo/backend/report/entity/Report.java +++ b/src/main/java/com/dodo/backend/report/entity/Report.java @@ -80,4 +80,13 @@ public class Report { @CreatedDate @Column(name = "report_created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportStatus 변경할 신고 처리 상태 + */ + public void updateReportStatus(ReportStatus reportStatus) { + this.reportStatus = reportStatus; + } } diff --git a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java index e84d098..59dcfe4 100644 --- a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java +++ b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java @@ -3,10 +3,15 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportStatus; import com.dodo.backend.user.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.UUID; + /** * {@link Report} 엔티티의 데이터베이스 접근을 담당하는 repository입니다. */ @@ -39,4 +44,89 @@ public interface ReportRepository extends JpaRepository { * @return 이미 신고했으면 true */ boolean existsByReporterAndComment(User reporter, Comment comment); + + /** + * 특정 게시글에 대한 신고 목록을 조회합니다. + * + * @param boardId 게시글 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoard_BoardIdOrderByCreatedAtDesc(Long boardId); + + /** + * 특정 유저에 대한 신고 목록을 조회합니다. + * + * @param userId 유저 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUser_UsersIdOrderByCreatedAtDesc(UUID userId); + + /** + * 특정 댓글에 대한 신고 목록을 조회합니다. + * + * @param commentId 댓글 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByComment_CommentIdOrderByCreatedAtDesc(Long commentId); + + /** + * 게시글 신고 목록을 조회합니다. + * + * @return 게시글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoardIsNotNull(); + + /** + * 특정 상태의 게시글 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 게시글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoardIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 유저 신고 목록을 조회합니다. + * + * @return 유저 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUserIsNotNull(); + + /** + * 특정 상태의 유저 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 유저 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUserIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 댓글 신고 목록을 조회합니다. + * + * @return 댓글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByCommentIsNotNull(); + + /** + * 특정 상태의 댓글 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 댓글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByCommentIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 댓글에 연결된 신고 내역을 삭제합니다. + * + * @param comment 삭제 대상 댓글 + */ + void deleteAllByComment(Comment comment); } diff --git a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java new file mode 100644 index 0000000..08659a4 --- /dev/null +++ b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java @@ -0,0 +1,120 @@ +package com.dodo.backend.admin.controller; + +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.report.entity.ReportStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 관리자 컨트롤러 API 경로를 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class AdminControllerTest { + + @Mock + private AdminService adminService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(adminService)) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + objectMapper = new ObjectMapper(); + } + + /** + * 게시글 신고 상세 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 상세 조회 API 성공") + void getBoardReportDetail_Success() throws Exception { + BoardReportDetailResponse response = BoardReportDetailResponse.builder() + .boardId(1L) + .totalReportCount(0) + .reports(List.of()) + .build(); + given(adminService.getBoardReportDetail(1L)).willReturn(response); + + mockMvc.perform(get("/admin/reports/board/{boardId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.boardId").value(1)); + + verify(adminService).getBoardReportDetail(1L); + } + + /** + * 신고 목록 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("신고 목록 조회 API 성공") + void getReportList_Success() throws Exception { + ReportListResponse response = ReportListResponse.builder() + .pageInfo(PageInfoResponse.toDto(0, 10, 0)) + .data(List.of()) + .build(); + given(adminService.getReportList(any(), eq(null), eq(0), eq(10), eq("lastReportedAt,desc"))).willReturn(response); + + mockMvc.perform(get("/admin/reports") + .param("reportType", "BOARD") + .param("sort", "lastReportedAt,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageInfo.page").value(0)); + } + + /** + * 신고 처리 상태 변경 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("신고 처리 상태 변경 API 성공") + void updateReportStatus_Success() throws Exception { + given(adminService.updateReportStatus(eq(10L), any(ReportStatusUpdateRequest.class))) + .willReturn(AdminSimpleResponse.toDto("성공적으로 상태를 변경했습니다.")); + + mockMvc.perform(patch("/admin/reports/{reportId}/status", 10L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReportStatusUpdateRequest(ReportStatus.COMPLETED)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("성공적으로 상태를 변경했습니다.")); + } + + /** + * 게시글 강제 삭제 API가 204를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 강제 삭제 API 성공") + void deleteBoard_Success() throws Exception { + mockMvc.perform(delete("/admin/boards/{boardId}", 1L)) + .andExpect(status().isNoContent()); + + verify(adminService).deleteBoard(1L); + } +} diff --git a/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java new file mode 100644 index 0000000..feea5bc --- /dev/null +++ b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java @@ -0,0 +1,175 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.mapper.UserMapper; +import com.dodo.backend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 관리자 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private BoardRepository boardRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private BoardMapper boardMapper; + + @Mock + private ImageFileService imageFileService; + + @InjectMocks + private AdminServiceImpl adminService; + + /** + * 게시글 신고 상세 조회 시 게시글과 신고 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 상세 조회 성공") + void getBoardReportDetail_Success() { + User writer = createUser(UUID.randomUUID(), "작성자"); + User reporter = createUser(UUID.randomUUID(), "신고자"); + Board board = createBoard(1L, writer); + Report report = createBoardReport(10L, reporter, board, ReportReason.ABUSE); + + given(boardRepository.findById(1L)).willReturn(Optional.of(board)); + given(reportRepository.findAllByBoard_BoardIdOrderByCreatedAtDesc(1L)).willReturn(List.of(report)); + + BoardReportDetailResponse response = adminService.getBoardReportDetail(1L); + + assertNotNull(response); + assertEquals(1L, response.getBoardId()); + assertEquals(1, response.getTotalReportCount()); + assertEquals("작성자", response.getReportedUserInfo().getNickname()); + } + + /** + * 신고 목록 조회 시 대상별로 신고가 묶이는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 목록 조회 성공") + void getReportList_Success() { + User writer = createUser(UUID.randomUUID(), "작성자"); + User reporter = createUser(UUID.randomUUID(), "신고자"); + Board board = createBoard(1L, writer); + Report report = createBoardReport(10L, reporter, board, ReportReason.SPAM); + + given(reportRepository.findAllByBoardIsNotNull()).willReturn(List.of(report)); + + ReportListResponse response = adminService.getReportList(AdminReportType.BOARD, null, 0, 10, "lastReportedAt,desc"); + + assertNotNull(response); + assertEquals(1, response.getData().size()); + assertEquals("BOARD", response.getData().get(0).getReportType()); + assertEquals(1, response.getData().get(0).getTotalReportCount()); + } + + /** + * 유저 상태 변경 시 UserMapper가 호출되는지 검증합니다. + */ + @Test + @DisplayName("유저 계정 상태 변경 성공") + void updateUserStatus_Success() { + UUID userId = UUID.randomUUID(); + given(userRepository.existsById(userId)).willReturn(true); + + adminService.updateUserStatus(userId, new UserStatusUpdateRequest(UserStatus.SUSPENDED)); + + verify(userMapper).updateUserStatus(userId, UserStatus.SUSPENDED.name()); + } + + /** + * 신고 상태 변경 시 엔티티의 상태가 변경되는지 검증합니다. + */ + @Test + @DisplayName("신고 처리 상태 변경 성공") + void updateReportStatus_Success() { + Report report = createBoardReport( + 10L, + createUser(UUID.randomUUID(), "신고자"), + createBoard(1L, createUser(UUID.randomUUID(), "작성자")), + ReportReason.SPAM + ); + given(reportRepository.findById(10L)).willReturn(Optional.of(report)); + + adminService.updateReportStatus(10L, new ReportStatusUpdateRequest(ReportStatus.COMPLETED)); + + assertEquals(ReportStatus.COMPLETED, report.getReportStatus()); + } + + private User createUser(UUID userId, String nickname) { + return User.builder() + .usersId(userId) + .nickname(nickname) + .build(); + } + + private Board createBoard(Long boardId, User user) { + return Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("게시글 제목") + .boardContent("게시글 내용") + .boardType(BoardType.FREE) + .boardStatus(BoardStatus.PUBLISHED) + .boardCreatedAt(LocalDateTime.of(2025, 10, 1, 10, 0)) + .build(); + } + + private Report createBoardReport(Long reportId, User reporter, Board board, ReportReason reason) { + return Report.builder() + .reportId(reportId) + .reporter(reporter) + .board(board) + .reportReason(reason) + .reportStatus(ReportStatus.PENDING) + .createdAt(LocalDateTime.of(2025, 10, 1, 11, 0)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java index 04d6701..ea60201 100644 --- a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java +++ b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java @@ -127,7 +127,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(List.of(notice)); given(imageFileService.getBoardImageUrls(notice.getBoardId())) .willReturn(List.of("https://example.com/notice/11.png")); @@ -174,7 +174,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); verify(imageFileService).getBoardImageUrls(notice.getBoardId()); @@ -204,7 +204,7 @@ void getMainPage_WithoutPets_ReturnsEmptyLists() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(Collections.emptyList()); // when @@ -220,7 +220,7 @@ void getMainPage_WithoutPets_ReturnsEmptyLists() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); verifyNoInteractions(healthAnalysisService); verifyNoInteractions(imageFileService); @@ -270,7 +270,7 @@ void getMainPage_PetWithoutHealthAnalysis() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(Collections.emptyList()); // when @@ -287,7 +287,7 @@ void getMainPage_PetWithoutHealthAnalysis() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); log.info("테스트 종료: 건강 분석 이력 없음 응답 검증 완료"); From cd8716b27d6cdf6c5f0418ac2280a31d0a73ca26 Mon Sep 17 00:00:00 2001 From: limhb708 Date: Tue, 16 Jun 2026 20:33:13 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20JavaDoc=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.java | 47 +++++++++++++++++++ .../admin/service/AdminServiceImpl.java | 43 +++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/main/java/com/dodo/backend/admin/controller/AdminController.java b/src/main/java/com/dodo/backend/admin/controller/AdminController.java index aad64d7..58250db 100644 --- a/src/main/java/com/dodo/backend/admin/controller/AdminController.java +++ b/src/main/java/com/dodo/backend/admin/controller/AdminController.java @@ -52,6 +52,9 @@ public class AdminController { /** * 특정 게시글의 신고 상세 내역을 조회합니다. + * + * @param boardId 신고 상세 내역을 조회할 게시글 ID + * @return 게시글 신고 상세 내역 */ @Operation(summary = "게시글 신고 상세 조회", description = "특정 게시글에 접수된 신고 상세 내역을 조회합니다.") @GetMapping("/reports/board/{boardId}") @@ -62,6 +65,9 @@ public ResponseEntity getBoardReportDetail(@PathVaria /** * 특정 유저의 신고 상세 내역을 조회합니다. + * + * @param userId 신고 상세 내역을 조회할 유저 ID + * @return 유저 신고 상세 내역 */ @Operation(summary = "유저 신고 상세 조회", description = "특정 유저에 접수된 신고 상세 내역을 조회합니다.") @GetMapping("/reports/user/{userId}") @@ -72,6 +78,9 @@ public ResponseEntity getUserReportDetail(@PathVariabl /** * 특정 댓글의 신고 상세 내역을 조회합니다. + * + * @param commentId 신고 상세 내역을 조회할 댓글 ID + * @return 댓글 신고 상세 내역 */ @Operation(summary = "댓글 신고 상세 조회", description = "특정 댓글에 접수된 신고 상세 내역을 조회합니다.") @GetMapping("/reports/comment/{commentId}") @@ -82,6 +91,13 @@ public ResponseEntity getCommentReportDetail(@PathV /** * 신고 목록을 조회합니다. + * + * @param reportType 조회할 신고 대상 유형 + * @param reportStatus 조회할 신고 처리 상태 + * @param page 조회할 페이지 번호 + * @param size 페이지당 신고 목록 개수 + * @param sort 정렬 조건 + * @return 신고 목록 조회 결과 */ @Operation(summary = "신고 목록 조회", description = "신고 유형과 처리 상태에 따라 신고 목록을 조회합니다.") @GetMapping("/reports") @@ -99,6 +115,10 @@ public ResponseEntity getReportList( /** * 유저 계정 상태를 변경합니다. + * + * @param userId 상태를 변경할 유저 ID + * @param request 변경할 유저 상태 요청 + * @return 상태 변경 성공 메시지 */ @Operation(summary = "유저 계정 상태 변경", description = "관리자가 유저 계정 상태를 변경합니다.") @PatchMapping("/users/{userId}/status") @@ -112,6 +132,9 @@ public ResponseEntity updateUserStatus( /** * 게시글을 강제로 삭제합니다. + * + * @param boardId 삭제할 게시글 ID + * @return 응답 본문이 없는 204 응답 */ @Operation(summary = "게시글 강제 삭제", description = "관리자가 게시글을 삭제 상태로 변경합니다.") @DeleteMapping("/boards/{boardId}") @@ -123,6 +146,9 @@ public ResponseEntity deleteBoard(@PathVariable Long boardId) { /** * 댓글을 강제로 삭제합니다. + * + * @param commentId 삭제할 댓글 ID + * @return 응답 본문이 없는 204 응답 */ @Operation(summary = "댓글 강제 삭제", description = "관리자가 댓글을 강제로 삭제합니다.") @DeleteMapping("/comments/{commentId}") @@ -134,6 +160,10 @@ public ResponseEntity deleteComment(@PathVariable Long commentId) { /** * 신고 처리 상태를 변경합니다. + * + * @param reportId 상태를 변경할 신고 ID + * @param request 변경할 신고 처리 상태 요청 + * @return 상태 변경 성공 메시지 */ @Operation(summary = "신고 처리 상태 변경", description = "관리자가 신고 처리 상태를 변경합니다.") @PatchMapping("/reports/{reportId}/status") @@ -147,6 +177,10 @@ public ResponseEntity updateReportStatus( /** * 공지를 작성합니다. + * + * @param request 공지 작성 요청 + * @param userDetails 인증된 관리자 정보 + * @return 공지 작성 성공 메시지 */ @Operation(summary = "공지 작성", description = "관리자가 공지를 작성합니다.") @PostMapping("/announcements") @@ -163,6 +197,9 @@ public ResponseEntity createAnnouncement( /** * 공지를 삭제합니다. + * + * @param boardId 삭제할 공지 게시글 ID + * @return 응답 본문이 없는 204 응답 */ @Operation(summary = "공지 삭제", description = "관리자가 공지를 삭제합니다.") @DeleteMapping("/announcements/{boardId}") @@ -174,6 +211,10 @@ public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { /** * 공지를 수정합니다. + * + * @param boardId 수정할 공지 게시글 ID + * @param request 공지 수정 요청 + * @return 응답 본문이 없는 204 응답 */ @Operation(summary = "공지 수정", description = "관리자가 공지를 수정합니다.") @PatchMapping("/announcements/{boardId}") @@ -188,6 +229,9 @@ public ResponseEntity updateAnnouncement( /** * 공지 목록을 조회합니다. + * + * @param pageable 공지 목록 페이지 요청 정보 + * @return 공지 목록 조회 결과 */ @Operation(summary = "공지 목록 조회", description = "공지 목록을 페이지 단위로 조회합니다.") @GetMapping("/announcements") @@ -200,6 +244,9 @@ public ResponseEntity getAnnouncementList( /** * 공지 상세를 조회합니다. + * + * @param boardId 조회할 공지 게시글 ID + * @return 공지 상세 정보 */ @Operation(summary = "공지 상세 조회", description = "공지 상세 내용을 조회합니다.") @GetMapping("/announcements/{boardId}") diff --git a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java index 04cdb79..af16347 100644 --- a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java +++ b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java @@ -78,6 +78,9 @@ public class AdminServiceImpl implements AdminService { /** * 특정 게시글의 신고 상세 내역을 조회합니다. + * + * @param boardId 신고 상세 내역을 조회할 게시글 ID + * @return 게시글 신고 상세 내역 */ @Transactional(readOnly = true) @Override @@ -98,6 +101,9 @@ public BoardReportDetailResponse getBoardReportDetail(Long boardId) { /** * 특정 유저의 신고 상세 내역을 조회합니다. + * + * @param userId 신고 상세 내역을 조회할 유저 ID + * @return 유저 신고 상세 내역 */ @Transactional(readOnly = true) @Override @@ -119,6 +125,9 @@ public UserReportDetailResponse getUserReportDetail(UUID userId) { /** * 특정 댓글의 신고 상세 내역을 조회합니다. + * + * @param commentId 신고 상세 내역을 조회할 댓글 ID + * @return 댓글 신고 상세 내역 */ @Transactional(readOnly = true) @Override @@ -133,6 +142,13 @@ public CommentReportDetailResponse getCommentReportDetail(Long commentId) { /** * 신고 유형별 신고 목록을 조회합니다. + * + * @param reportType 조회할 신고 대상 유형 + * @param reportStatus 조회할 신고 처리 상태 + * @param page 조회할 페이지 번호 + * @param size 페이지당 신고 목록 개수 + * @param sort 정렬 조건 + * @return 신고 목록 조회 결과 */ @Transactional(readOnly = true) @Override @@ -155,6 +171,10 @@ public ReportListResponse getReportList(AdminReportType reportType, ReportStatus /** * 유저 계정 상태를 변경합니다. + * + * @param userId 상태를 변경할 유저 ID + * @param request 변경할 유저 상태 요청 + * @return 상태 변경 성공 메시지 */ @Transactional @Override @@ -172,6 +192,8 @@ public AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest /** * 게시글을 강제로 삭제합니다. + * + * @param boardId 삭제할 게시글 ID */ @Transactional @Override @@ -187,6 +209,8 @@ public void deleteBoard(Long boardId) { /** * 댓글을 강제로 삭제합니다. + * + * @param commentId 삭제할 댓글 ID */ @Transactional @Override @@ -201,6 +225,10 @@ public void deleteComment(Long commentId) { /** * 신고 처리 상태를 변경합니다. + * + * @param reportId 상태를 변경할 신고 ID + * @param request 변경할 신고 처리 상태 요청 + * @return 상태 변경 성공 메시지 */ @Transactional @Override @@ -219,6 +247,10 @@ public AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateR /** * 공지를 작성합니다. + * + * @param adminId 공지를 작성하는 관리자 ID + * @param request 공지 작성 요청 + * @return 공지 작성 성공 메시지 */ @Transactional @Override @@ -246,6 +278,8 @@ public AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRe /** * 공지를 삭제합니다. + * + * @param boardId 삭제할 공지 게시글 ID */ @Transactional @Override @@ -257,6 +291,9 @@ public void deleteAnnouncement(Long boardId) { /** * 공지를 수정합니다. + * + * @param boardId 수정할 공지 게시글 ID + * @param request 공지 수정 요청 */ @Transactional @Override @@ -272,6 +309,9 @@ public void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request) /** * 공지 목록을 조회합니다. + * + * @param pageable 공지 목록 페이지 요청 정보 + * @return 공지 목록 조회 결과 */ @Transactional(readOnly = true) @Override @@ -293,6 +333,9 @@ public AnnouncementListResponse getAnnouncementList(Pageable pageable) { /** * 공지 상세를 조회합니다. + * + * @param boardId 조회할 공지 게시글 ID + * @return 공지 상세 정보 */ @Transactional(readOnly = true) @Override