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 e3b6e84..11f2519 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -1,7 +1,8 @@ package com.dodo.backend.board.controller; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; -import com.dodo.backend.board.dto.response.BoardResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardCreateResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.service.BoardService; import com.dodo.backend.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; @@ -11,6 +12,7 @@ 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; @@ -20,6 +22,9 @@ import java.util.UUID; +/** + * 게시글 관련 HTTP 요청을 처리하는 컨트롤러입니다. + */ @RestController @RequiredArgsConstructor @RequestMapping("/boards") @@ -29,10 +34,13 @@ public class BoardController { private final BoardService boardService; + /** + * 게시글 작성 + */ @Operation(summary = "새로운 게시글 작성", description = "새로운 게시글을 작성합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "게시글이 성공적으로 작성되었습니다.", - content = @Content(schema = @Schema(implementation = BoardResponse.BoardCreateResponse.class))), + content = @Content(schema = @Schema(implementation = BoardCreateResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -48,6 +56,51 @@ public class BoardController { schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "403 Forbidden", value = "{\"status\": 403, \"message\": \"게시글을 생성할 권한이 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "500 Internal Server Error", + value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PostMapping + public ResponseEntity createBoard( + @Valid @RequestBody BoardCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("게시글 작성 요청 수신 - User: {}, Title: {}", userId, request.getBoardTitle()); + + Long boardId = boardService.createBoard(userId, request); + + BoardCreateResponse response = + BoardCreateResponse.toDto(boardId, "게시글이 성공적으로 작성되었습니다."); + + return ResponseEntity.ok(response); + } + + /** + * 게시글 상세 조회 + */ + @Operation(summary = "특정 게시글 상세 조회", description = "게시글 ID를 기반으로 게시글 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "게시글 상세 조회에 성공했습니다.", + content = @Content(schema = @Schema(implementation = BoardDetailResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "400 Bad Request", + value = "{\"status\": 400, \"message\": \"잘못된 요청입니다.\"}"))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "401 Unauthorized", + value = "{\"status\": 401, \"message\": \"로그인이 필요한 기능입니다.\"}"))), + @ApiResponse(responseCode = "403", description = "특정 게시글을 조회할 권한이 없습니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "403 Forbidden", + value = "{\"status\": 403, \"message\": \"특정 게시글을 조회할 권한이 없습니다.\"}"))), @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -59,22 +112,16 @@ public class BoardController { examples = @ExampleObject(name = "500 Internal Server Error", value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) }) - /** - * 게시글 작성 - */ - @PostMapping - public ResponseEntity createBoard( - @RequestBody BoardCreateRequest request, + @GetMapping("/{boardId}") + public ResponseEntity getBoardDetail( + @PathVariable Long boardId, @AuthenticationPrincipal UserDetails userDetails ) { UUID userId = UUID.fromString(userDetails.getUsername()); - log.info("게시글 작성 요청 수신 - User: {}, Title: {}", userId, request.getBoardTitle()); - - Long boardId = boardService.createBoard(userId, request); + log.info("게시글 상세 조회 요청 수신 - User: {}, BoardId: {}", userId, boardId); - BoardResponse.BoardCreateResponse response = - BoardResponse.BoardCreateResponse.toDto(boardId, "게시글이 성공적으로 작성되었습니다."); + BoardDetailResponse response = boardService.getBoardDetail(boardId); return ResponseEntity.ok(response); } 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 9e2347b..f0aeb09 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 @@ -34,7 +34,7 @@ public static class BoardCreateRequest { @NotBlank(message = "내용은 필수입니다.") private String boardContent; - @Schema(description = "게시글 이미지 URL 목록") + @Schema(description = "게시글 이미지 URL 목록", nullable = true) 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 b55f3ac..48ca5ab 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 @@ -5,7 +5,6 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; import java.time.LocalDateTime; @@ -37,4 +36,55 @@ public static BoardCreateResponse toDto(Long boardId, String message) { .build(); } } + + /** + * 게시글 상세 조회 응답 DTO + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "게시글 상세 조회 응답") + public static class BoardDetailResponse { + + @Schema(description = "응답 메시지", example = "게시글 상세 조회에 성공했습니다.") + private String message; + + @Schema(description = "게시글 ID", example = "123") + private Long boardId; + + @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; + + @Schema(description = "작성자 닉네임", example = "자유로운영혼") + private String nickname; + + @Schema(description = "조회수", example = "51") + private Integer viewCount; + + @Schema(description = "게시글 생성 시각", example = "2025-10-06T10:00:00") + private LocalDateTime boardCreatedAt; + + @Schema(description = "게시글 수정 시각", example = "2025-10-06T11:00:00") + private LocalDateTime modifiedAt; + + public static BoardDetailResponse toDto(Board board, String imageFileUrl, String message) { + return BoardDetailResponse.builder() + .message(message) + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .nickname(board.getUser().getNickname()) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .modifiedAt(board.getModifiedAt()) + .build(); + } + } } \ No newline at end of file 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 f8d17f9..d1b4eda 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -1,11 +1,11 @@ package com.dodo.backend.board.service; -import com.dodo.backend.board.dto.response.BoardResponse; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.entity.Board; -import com.dodo.backend.board.dto.request.BoardRequest; import java.util.UUID; + /** * 게시물(Board) 도메인의 비즈니스 로직을 처리하는 서비스 인터페이스입니다. */ @@ -21,7 +21,18 @@ public interface BoardService { /** * 게시글 생성 + * + * @param userId 요청 사용자 ID + * @param request 게시글 생성 요청 DTO + * @return 생성된 게시글 ID */ Long createBoard(UUID userId, BoardCreateRequest request); + /** + * 게시글 상세 조회 + * + * @param boardId 게시글 ID + * @return 게시글 상세 조회 응답 DTO + */ + BoardDetailResponse getBoardDetail(Long boardId); } \ No newline at end of file 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 64b1f5b..2b21701 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -1,24 +1,27 @@ package com.dodo.backend.board.service; -import com.dodo.backend.board.dto.request.BoardRequest; - +import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; -import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.repository.ImageFileRepository; 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.board.exception.BoardErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.board.exception.BoardErrorCode.INVALID_REQUEST; /** * {@link BoardService} 구현체입니다. - + * * 게시글 관련 비즈니스 로직을 처리하는 서비스 클래스입니다. */ @Service @@ -30,6 +33,11 @@ public class BoardServiceImpl implements BoardService { */ private final BoardRepository boardRepository; + /** + * 이미지 파일 저장 / 조회를 위한 Repository + */ + private final ImageFileRepository imageFileRepository; + /** * 사용자 조회를 위한 UserService */ @@ -38,10 +46,12 @@ public class BoardServiceImpl implements BoardService { /** * 게시글 ID로 게시글 엔티티 조회 */ - @Transactional(readOnly = true) @Override + @Transactional(readOnly = true) public Board getBoardById(Long boardId) { + validateBoardId(boardId); + return boardRepository.findById(boardId) .orElseThrow(() -> new BoardException(BOARD_NOT_FOUND)); } @@ -60,9 +70,116 @@ public Long createBoard(UUID userId, BoardCreateRequest request) { User user = userService.getUserById(userId); Board board = request.toEntity(user); - Board savedBoard = boardRepository.save(board); + saveBoardImages(savedBoard, request.getImageFileUrls()); + return savedBoard.getBoardId(); } + + /** + * 게시글 상세 조회 + * + * @param boardId 게시글 ID + * @return 게시글 상세 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public BoardDetailResponse getBoardDetail(Long boardId) { + + Board board = getBoardById(boardId); + + String imageFileUrl = imageFileRepository.findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId) + .map(ImageFile::getImageFileUrl) + .orElseGet(() -> null); + + return BoardDetailResponse.toDto(board, imageFileUrl, "게시글 상세 조회에 성공했습니다."); + } + + /** + * 게시글 ID를 검증합니다. + * + * @param boardId 게시글 ID + */ + private void validateBoardId(Long boardId) { + + if (boardId == null) { + throw new BoardException(INVALID_REQUEST); + } + } + + /** + * 게시글 이미지 URL 목록을 저장합니다. + * + * @param board 게시글 엔티티 + * @param imageFileUrls 게시글 이미지 URL 목록 + */ + private void saveBoardImages(Board board, List imageFileUrls) { + + if (board == null) { + throw new BoardException(INVALID_REQUEST); + } + + List validImageFileUrls = getValidImageFileUrls(imageFileUrls); + + if (validImageFileUrls.isEmpty()) { + return; + } + + List imageFiles = validImageFileUrls.stream() + .map(imageFileUrl -> ImageFile.builder() + .board(board) + .imageFileUrl(imageFileUrl) + .size(0L) + .originalFilename(extractOriginalFilename(imageFileUrl)) + .build()) + .toList(); + + imageFileRepository.saveAll(imageFiles); + } + + /** + * 유효한 이미지 URL 목록을 반환합니다. + * + * @param imageFileUrls 이미지 URL 목록 + * @return 유효한 이미지 URL 목록 + */ + private List getValidImageFileUrls(List imageFileUrls) { + + if (imageFileUrls == null || imageFileUrls.isEmpty()) { + return List.of(); + } + + return imageFileUrls.stream() + .filter(imageFileUrl -> imageFileUrl != null && !imageFileUrl.isBlank()) + .map(String::trim) + .distinct() + .toList(); + } + + /** + * 이미지 URL에서 원본 파일명으로 사용할 값을 추출합니다. + * + * @param imageFileUrl 이미지 URL + * @return 추출된 파일명 + */ + private String extractOriginalFilename(String imageFileUrl) { + + String urlWithoutQueryString = imageFileUrl.split("\\?")[0]; + int lastSlashIndex = urlWithoutQueryString.lastIndexOf('/'); + + String originalFilename = lastSlashIndex >= 0 + ? urlWithoutQueryString.substring(lastSlashIndex + 1) + : urlWithoutQueryString; + + if (originalFilename.isBlank()) { + return "board-image"; + } + + if (originalFilename.length() <= 255) { + return originalFilename; + } + + return originalFilename.substring(originalFilename.length() - 255); + } } \ No newline at end of file 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 5123ca0..f7323bd 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.fence.exception.FenceException; import com.dodo.backend.healthanalysis.exception.HealthAnalysisException; +import com.dodo.backend.imagefile.exception.ImageFileException; import com.dodo.backend.pet.exception.PetException; import com.dodo.backend.petweight.exception.PetWeightException; import com.dodo.backend.reaction.exception.ReactionException; @@ -125,6 +126,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 이미지 파일(ImageFile) 도메인 비즈니스 로직에서 발생하는 {@link ImageFileException}을 처리합니다. + */ + @ExceptionHandler(ImageFileException.class) + protected ResponseEntity handleImageFileException(ImageFileException e) { + log.error("ImageFileException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * {@code @Valid} 어노테이션을 통한 요청 데이터 유효성 검증 실패 시 발생하는 예외를 처리합니다. */ @@ -164,4 +174,4 @@ protected ResponseEntity handleException(Exception e) { log.error("Internal Server Error : {}", e.getMessage(), e); return toResponseEntity(INTERNAL_SERVER_ERROR); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java b/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java index 27746e4..139fc4a 100644 --- a/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java +++ b/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java @@ -1,5 +1,6 @@ package com.dodo.backend.imagefile.entity; +import com.dodo.backend.board.entity.Board; import com.dodo.backend.pet.entity.Pet; import jakarta.persistence.*; import lombok.*; @@ -9,10 +10,11 @@ import java.time.LocalDateTime; /** - * 펫의 프로필 이미지 파일 정보를 관리하는 엔티티입니다. + * 이미지 파일 정보를 관리하는 엔티티입니다. *

- * {@link Pet} 엔티티와 1:1 관계를 맺으며, 파일의 메타데이터(크기, 원본명 등)와 - * 저장된 URL 경로를 포함합니다. + * 펫 프로필 이미지 또는 게시글 이미지로 사용될 수 있으며, + * 파일의 메타데이터(크기, 원본명 등)와 저장된 URL 경로를 포함합니다. + *

*/ @Entity @Getter @@ -29,9 +31,13 @@ public class ImageFile { private Long imageFileId; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "pet_id", nullable = false, unique = true) + @JoinColumn(name = "pet_id", unique = true) private Pet pet; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + @Column(name = "image_file_url", length = 2048, nullable = false) private String imageFileUrl; diff --git a/src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java new file mode 100644 index 0000000..cc5ad2a --- /dev/null +++ b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java @@ -0,0 +1,48 @@ +package com.dodo.backend.imagefile.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 이미지 파일(ImageFile) 도메인에서 발생하는 예외 상황을 관리하는 에러 코드 정의 클래스입니다. + *

+ * 이미지 업로드, 프로필 이미지 저장 및 수정 과정에서 발생할 수 있는 예외를 포함하며, + * HTTP 상태 코드와 클라이언트에게 전달할 메시지를 {@code Key-Value} 형태로 관리합니다. + */ +@AllArgsConstructor +@Getter +public enum ImageFileErrorCode implements BaseErrorCode { + + /** + * 클라이언트의 요청 형식이 잘못되었거나 이미지 파일 검증을 통과하지 못했을 때 사용합니다. + *

+ * HTTP {@code 400 Bad Request}를 반환합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 이미지 업로드 결과에서 유효한 이미지 URL을 확인할 수 없을 때 사용합니다. + *

+ * HTTP {@code 500 Internal Server Error}를 반환합니다. + */ + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드 중 오류가 발생했습니다."), + + /** + * 서버 내부에서 예상치 못한 오류가 발생했을 때 사용합니다. + *

+ * HTTP {@code 500 Internal Server Error}를 반환합니다. + */ + 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/imagefile/exception/ImageFileException.java b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileException.java new file mode 100644 index 0000000..3d0485b --- /dev/null +++ b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileException.java @@ -0,0 +1,22 @@ +package com.dodo.backend.imagefile.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 이미지 파일(ImageFile) 도메인 비즈니스 로직 수행 중 발생하는 전용 예외 클래스입니다. + *

+ * 이미지 업로드, 프로필 이미지 저장 및 수정 등 이미지 파일 관련 서비스 로직에서 + * 예외 상황 발생 시 이 클래스를 throw 합니다. + * {@code GlobalExceptionHandler}에서 이 예외를 가로채어 + * {@link ImageFileErrorCode}에 정의된 표준 응답으로 변환합니다. + */ +@AllArgsConstructor +@Getter +public class ImageFileException extends RuntimeException { + + /** + * 발생한 예외의 구체적인 종류(상태 코드, 메시지)를 담고 있는 Enum입니다. + */ + private final ImageFileErrorCode errorCode; +} diff --git a/src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java b/src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java new file mode 100644 index 0000000..c100fc5 --- /dev/null +++ b/src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java @@ -0,0 +1,22 @@ +package com.dodo.backend.imagefile.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ImageFileMapper { + + /** + * 펫 프로필 이미지 URL을 수정합니다. + * + * @param petId 수정할 펫 ID + * @param imageFileUrl 새로운 이미지 URL + * @param originalFilename 이미지 URL에서 추출한 파일명 + * @return 수정된 행 수 + */ + int updatePetProfileImage( + @Param("petId") Long petId, + @Param("imageFileUrl") String imageFileUrl, + @Param("originalFilename") String originalFilename + ); +} diff --git a/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java b/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java index 2b1dde0..a9e8ca1 100644 --- a/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java +++ b/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java @@ -25,9 +25,18 @@ public interface ImageFileRepository extends JpaRepository { * 여러 펫 ID들에 해당하는 이미지 파일들을 일괄 조회합니다. *

* 펫 목록 조회 시 N+1 문제를 방지하기 위해 사용됩니다. + *

* * @param petIds 조회할 펫 ID 목록 * @return 이미지 파일 엔티티 리스트 */ List findAllByPet_PetIdIn(List petIds); + + /** + * 특정 게시글 ID에 해당하는 첫 번째 이미지 파일을 조회합니다. + * + * @param boardId 게시글 ID + * @return 게시글 대표 이미지 파일 + */ + Optional findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(Long boardId); } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java index c0fc2ec..596081e 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java @@ -1,6 +1,7 @@ package com.dodo.backend.imagefile.service; import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; +import com.dodo.backend.pet.entity.Pet; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -26,4 +27,21 @@ public interface ImageFileService { * @return 업로드 응답 DTO */ ImageUploadResponse uploadImages(List files); + + /** + * 펫 프로필 이미지 URL을 저장합니다. + * + * @param pet 이미지와 연결할 펫 + * @param imageFileUrl 저장할 이미지 URL + */ + void savePetProfileImage(Pet pet, String imageFileUrl); + + /** + * 펫 프로필 이미지 URL을 수정합니다. + * 기존 이미지 정보가 없으면 새로 저장합니다. + * + * @param pet 이미지와 연결할 펫 + * @param imageFileUrl 수정할 이미지 URL + */ + void updatePetProfileImage(Pet pet, String imageFileUrl); } diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java index 5f2e7a3..f29e685 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java @@ -4,13 +4,16 @@ import com.cloudinary.utils.ObjectUtils; import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.exception.ImageFileException; +import com.dodo.backend.imagefile.mapper.ImageFileMapper; import com.dodo.backend.imagefile.repository.ImageFileRepository; -import com.dodo.backend.user.exception.UserException; +import com.dodo.backend.pet.entity.Pet; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.tika.Tika; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.function.ThrowingFunction; import org.springframework.web.multipart.MultipartFile; import java.util.Collections; @@ -20,7 +23,8 @@ import java.util.Set; import java.util.stream.Collectors; -import static com.dodo.backend.user.exception.UserErrorCode.INVALID_REQUEST; +import static com.dodo.backend.imagefile.exception.ImageFileErrorCode.IMAGE_UPLOAD_FAILED; +import static com.dodo.backend.imagefile.exception.ImageFileErrorCode.INVALID_REQUEST; /** * {@link ImageFileService}의 구현체입니다. @@ -38,18 +42,18 @@ public class ImageFileServiceImpl implements ImageFileService { ); private final ImageFileRepository imageFileRepository; + private final ImageFileMapper imageFileMapper; private final Cloudinary cloudinary; private final Tika tika = new Tika(); /** - * {@inheritDoc} + * 펫 ID 목록에 해당하는 프로필 이미지 URL을 일괄 조회합니다. *

- * 처리 과정: - *

    - *
  1. 입력된 펫 ID 목록이 비어있으면 빈 Map을 반환합니다.
  2. - *
  3. 리포지토리의 {@code findAllByPet_PetIdIn}을 호출하여 펫 ID 목록에 해당하는 이미지 파일들을 조회합니다.
  4. - *
  5. 조회된 엔티티에서 펫 ID와 이미지 URL을 추출하여 Map으로 변환합니다.
  6. - *
+ * 입력된 펫 ID 목록이 비어 있으면 DB를 조회하지 않고 빈 Map을 반환합니다. + * 조회된 이미지 파일은 펫 ID를 key, 이미지 URL을 value로 변환합니다. + * + * @param petIds 이미지 URL을 조회할 펫 ID 목록 + * @return 펫 ID와 이미지 URL을 매핑한 Map */ @Override @Transactional(readOnly = true) @@ -69,62 +73,162 @@ public Map getProfileUrlsByPetIds(List petIds) { } /** - * {@inheritDoc} + * 이미지 파일 목록을 Cloudinary에 업로드하고 업로드된 이미지 URL 목록을 반환합니다. + *

+ * 요청 파일이 비어 있거나 허용되지 않은 MIME 타입이면 잘못된 요청 예외를 발생시키고, + * 정상 파일은 개별 업로드 후 secure URL만 응답 DTO에 담습니다. + * + * @param files 업로드할 이미지 파일 목록 + * @return 업로드 성공 메시지와 이미지 URL 목록 */ @Transactional(readOnly = true) @Override public ImageUploadResponse uploadImages(List files) { if (files == null || files.isEmpty()) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } List imageUrls = files.stream() - .map(this::uploadSingleImage) + .map(ThrowingFunction.of( + this::uploadSingleImage, + (message, exception) -> new ImageFileException(IMAGE_UPLOAD_FAILED) + )) .toList(); return ImageUploadResponse.toDto("이미지 업로드에 성공하였습니다.", imageUrls); } - private String uploadSingleImage(MultipartFile file) { + /** + * 펫 프로필 이미지 URL을 {@code image_file} 테이블에 신규 저장합니다. + *

+ * 이미지 URL이 비어 있으면 저장하지 않습니다. 파일 크기는 외부 URL만 전달받는 + * 현재 요청 구조상 알 수 없으므로 기본값 {@code 0L}로 저장합니다. + * + * @param pet 이미지와 연결할 펫 엔티티 + * @param imageFileUrl 저장할 이미지 URL + */ + @Transactional + @Override + public void savePetProfileImage(Pet pet, String imageFileUrl) { + if (pet == null || isBlank(imageFileUrl)) { + return; + } + + ImageFile imageFile = ImageFile.builder() + .pet(pet) + .imageFileUrl(imageFileUrl) + .size(0L) + .originalFilename(extractOriginalFilename(imageFileUrl)) + .build(); + + imageFileRepository.save(imageFile); + } + + /** + * 펫 프로필 이미지 URL을 수정합니다. + *

+ * 기존 이미지 행이 있으면 MyBatis mapper로 URL과 원본 파일명을 갱신하고, + * 기존 행이 없으면 신규 이미지 정보로 저장합니다. + * + * @param pet 이미지와 연결된 펫 엔티티 + * @param imageFileUrl 수정할 이미지 URL + */ + @Transactional + @Override + public void updatePetProfileImage(Pet pet, String imageFileUrl) { + if (pet == null || pet.getPetId() == null || isBlank(imageFileUrl)) { + return; + } + + int updatedRows = imageFileMapper.updatePetProfileImage( + pet.getPetId(), + imageFileUrl, + extractOriginalFilename(imageFileUrl) + ); + + if (updatedRows == 0) { + savePetProfileImage(pet, imageFileUrl); + } + } + + /** + * 단일 이미지 파일을 검증한 뒤 Cloudinary에 업로드합니다. + *

+ * 파일이 비어 있거나 허용되지 않은 MIME 타입이면 이미지 파일 도메인 예외를 발생시키고, + * 업로드 결과에 secure URL이 없으면 업로드 실패 예외를 발생시킵니다. + * + * @param file 업로드할 이미지 파일 + * @return Cloudinary에서 반환한 secure URL + * @throws Exception 파일 바이트 조회 또는 Cloudinary 업로드 중 checked exception이 발생한 경우 + */ + private String uploadSingleImage(MultipartFile file) throws Exception { if (file == null || file.isEmpty()) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } String contentType = file.getContentType(); if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } String detectedMimeType = detectMimeType(file); if (!ALLOWED_MIME_TYPES.contains(detectedMimeType)) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } - try { - Map uploadResult = cloudinary.uploader().upload( - file.getBytes(), - ObjectUtils.asMap( - "folder", "images", - "resource_type", "image" - ) - ); - Object url = uploadResult.get("secure_url"); - if (url == null) { - throw new IllegalStateException("Cloudinary 응답에 secure_url이 없습니다."); - } - return Objects.toString(url); - } catch (UserException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException("이미지 업로드 중 오류가 발생했습니다.", e); + Map uploadResult = cloudinary.uploader().upload( + file.getBytes(), + ObjectUtils.asMap( + "folder", "images", + "resource_type", "image" + ) + ); + Object url = uploadResult.get("secure_url"); + if (url == null) { + throw new ImageFileException(IMAGE_UPLOAD_FAILED); } + return Objects.toString(url); + } + + /** + * 파일 바이트를 기반으로 실제 MIME 타입을 감지합니다. + * + * @param file MIME 타입을 확인할 이미지 파일 + * @return 감지된 MIME 타입 + * @throws Exception 파일 바이트 조회 또는 MIME 타입 감지 중 checked exception이 발생한 경우 + */ + private String detectMimeType(MultipartFile file) throws Exception { + return tika.detect(file.getBytes()); } - private String detectMimeType(MultipartFile file) { - try { - return tika.detect(file.getBytes()); - } catch (Exception e) { - throw new UserException(INVALID_REQUEST); + /** + * 문자열이 null이거나 공백 문자열인지 확인합니다. + * + * @param value 확인할 문자열 + * @return null 또는 공백 문자열이면 true + */ + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + /** + * 이미지 URL에서 원본 파일명을 추출합니다. + *

+ * 쿼리 파라미터는 제거하고 마지막 경로 구간을 파일명으로 사용합니다. + * 추출할 수 없으면 기본 파일명을 반환합니다. + * + * @param imageFileUrl 파일명을 추출할 이미지 URL + * @return 추출된 파일명 또는 기본 파일명 + */ + private String extractOriginalFilename(String imageFileUrl) { + int queryStart = imageFileUrl.indexOf('?'); + String urlWithoutQuery = queryStart >= 0 ? imageFileUrl.substring(0, queryStart) : imageFileUrl; + int slashIndex = urlWithoutQuery.lastIndexOf('/'); + + if (slashIndex < 0 || slashIndex == urlWithoutQuery.length() - 1) { + return "pet_profile_image"; } + + return urlWithoutQuery.substring(slashIndex + 1); } } diff --git a/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java b/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java index ba6d1d2..9c26032 100644 --- a/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java +++ b/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java @@ -36,6 +36,9 @@ public static class PetRegisterRequest { @Schema(description = "반려동물 등록번호 (선택사항, 없을 시 null)", example = "null", nullable = true) private String registrationNumber; + @Schema(description = "반려동물 프로필 이미지 URL", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + @Schema(description = "성별 (MALE, FEMALE, NEUTER)", example = "MALE") @NotNull(message = "성별은 필수입니다.") private String sex; @@ -104,6 +107,9 @@ public static class PetUpdateRequest { @Size(max = 15, message = "등록번호는 15자 이하여야 합니다.") private String registrationNumber; + @Schema(description = "변경할 반려동물 프로필 이미지 URL (선택)", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + @Schema(description = "변경할 성별 (선택, MALE, FEMALE, NEUTER)", example = "FEMALE") private String sex; diff --git a/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java b/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java index 28e3340..9892385 100644 --- a/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java +++ b/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java @@ -95,6 +95,8 @@ public PetRegisterResponse registerPet(UUID userId, PetRegisterRequest request) Pet pet = request.toEntity(); Pet savedPet = petRepository.save(pet); + imageFileService.savePetProfileImage(savedPet, request.getImageFileUrl()); + userPetService.registerUserPet(userId, savedPet, RegistrationStatus.APPROVED); log.info("펫 등록 및 유저 관계 설정 완료 - User: {}, PetId: {}", userId, savedPet.getPetId()); @@ -136,7 +138,10 @@ public PetUpdateResponse updatePet(Long petId, PetUpdateRequest request, UUID us } } - petMapper.updatePetProfileInfo(request, petId); + if (hasPetTableUpdateFields(request)) { + petMapper.updatePetProfileInfo(request, petId); + } + imageFileService.updatePetProfileImage(pet, request.getImageFileUrl()); log.info("반려동물 프로필 수정 성공 - PetId: {}", petId); @@ -540,6 +545,27 @@ public PetDeviceCheckResponse checkDeviceIdAvailability(PetDeviceCheckRequest re return PetDeviceCheckResponse.toDto("사용 가능한 디바이스 ID입니다.", true); } + /** + * {@code pet} 테이블에 반영할 필드가 있는지 확인합니다. + *

+ * {@code imageFileUrl}은 {@code image_file} 도메인에서 별도로 처리하므로 + * 이 검사 대상에 포함하지 않습니다. 이미지 URL만 수정하는 요청에서 + * {@link PetMapper#updatePetProfileInfo}를 호출하면 MyBatis의 동적 + * {@code }에 들어갈 컬럼이 없어 잘못된 SQL이 생성될 수 있습니다. + * + * @param request 반려동물 수정 요청 DTO + * @return {@code pet} 테이블 업데이트 대상 필드가 하나 이상 있으면 true + */ + private boolean hasPetTableUpdateFields(PetUpdateRequest request) { + return request.getRegistrationNumber() != null + || request.getSex() != null + || request.getAge() != null + || request.getPetName() != null + || request.getBreed() != null + || request.getReferenceHeartRate() != null + || request.getDeviceId() != null; + } + /** * 반려동물 특이사항을 생성합니다. *

diff --git a/src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml b/src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml new file mode 100644 index 0000000..d10e9c3 --- /dev/null +++ b/src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml @@ -0,0 +1,21 @@ + + + + + + + + + UPDATE image_file + + image_file_url = #{imageFileUrl}, + original_filename = #{originalFilename} + + WHERE pet_id = #{petId} + + 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 6c4f8bf..0d84f81 100644 --- a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java +++ b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java @@ -1,7 +1,8 @@ package com.dodo.backend.board.controller; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; -import com.dodo.backend.board.dto.response.BoardResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardCreateResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.service.BoardService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; @@ -13,16 +14,21 @@ 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.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; /** * {@link BoardController}의 HTTP 요청 처리 로직을 검증하는 테스트 클래스입니다. + *

+ * 게시글 작성 및 게시글 상세 조회 API 호출 시, + * 컨트롤러가 요청 데이터를 올바르게 서비스 계층으로 전달하고 + * 기대한 응답 DTO를 반환하는지 검증합니다. + *

*/ @Slf4j @ExtendWith(MockitoExtension.class) @@ -32,14 +38,19 @@ class BoardControllerTest { private BoardService boardService; /** - * 게시글 작성 요청 시 200 상태코드와 생성된 게시글 ID를 반환하는지 검증합니다. + * 이미지 URL이 포함된 게시글 작성 요청 성공 시나리오를 테스트합니다. + *

+ * 인증된 사용자 정보와 게시글 작성 요청 DTO를 전달했을 때, + * 컨트롤러가 {@link BoardService#createBoard(UUID, BoardCreateRequest)}를 호출하고 + * 생성된 게시글 ID와 성공 메시지를 반환하는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 작성 성공: 200 상태코드와 게시글 ID를 반환한다.") - void createBoard_Success() { - log.info("테스트 시작: 게시글 작성 성공 시나리오"); + @DisplayName("게시글 작성 성공: 이미지가 있어도 정상 응답을 반환한다.") + void createBoard_Success_WithImages() { + log.info("이미지가 포함된 게시글 작성 성공 테스트를 시작합니다."); - // given + // Given: 게시글 작성 요청 데이터와 인증 사용자 정보를 준비합니다. BoardController boardController = new BoardController(boardService); UUID userId = UUID.randomUUID(); @@ -60,8 +71,54 @@ void createBoard_Success() { given(boardService.createBoard(userId, request)).willReturn(1L); + // When: 게시글 작성 API를 호출합니다. + ResponseEntity response = + boardController.createBoard(request, userDetails); + + // Then: 응답 상태, 메시지, 게시글 ID를 검증합니다. + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글이 성공적으로 작성되었습니다.", response.getBody().getMessage()); + assertEquals(1L, response.getBody().getBoardId()); + + verify(boardService).createBoard(userId, request); + + log.info("이미지가 포함된 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 작성 요청 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 {@code imageFileUrls}가 null이어도 + * 게시글 생성 요청이 정상적으로 처리되는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 작성 성공: 이미지가 없어도 정상 응답을 반환한다.") + void createBoard_Success_WithoutImages() { + log.info("이미지가 없는 게시글 작성 성공 테스트를 시작합니다."); + + // given + BoardController boardController = new BoardController(boardService); + + UUID userId = UUID.randomUUID(); + + UserDetails userDetails = User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + + BoardCreateRequest request = BoardCreateRequest.builder() + .boardTitle("이미지 없는 게시글") + .boardContent("이미지 없이 작성합니다.") + .imageFileUrls(null) + .build(); + + given(boardService.createBoard(userId, request)).willReturn(2L); + // when - ResponseEntity response = + ResponseEntity response = boardController.createBoard(request, userDetails); // then @@ -69,10 +126,125 @@ void createBoard_Success() { assertEquals(200, response.getStatusCode().value()); assertNotNull(response.getBody()); assertEquals("게시글이 성공적으로 작성되었습니다.", response.getBody().getMessage()); - assertEquals(1L, response.getBody().getBoardId()); + assertEquals(2L, response.getBody().getBoardId()); verify(boardService).createBoard(userId, request); - log.info("테스트 종료: 게시글 작성 성공 시나리오"); + log.info("이미지가 없는 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 있는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 게시글 상세 조회 요청 시 서비스 계층에서 반환한 대표 이미지 URL이 + * 응답 DTO의 {@code imageFileUrl} 필드에 포함되는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 상세 조회 성공: 이미지가 있으면 imageFileUrl을 반환한다.") + void getBoardDetail_Success_WithImage() { + log.info("이미지가 있는 게시글 상세 조회 성공 테스트를 시작합니다."); + + // given + BoardController boardController = new BoardController(boardService); + + UUID userId = UUID.randomUUID(); + Long boardId = 123L; + LocalDateTime boardCreatedAt = LocalDateTime.of(2025, 10, 6, 10, 0); + + UserDetails userDetails = User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + + BoardDetailResponse detailResponse = BoardDetailResponse.builder() + .message("게시글 상세 조회에 성공했습니다.") + .boardId(boardId) + .boardTitle("저희 강아지 자랑합니다!") + .boardContent("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") + .imageFileUrl("https://example.com/images/bori.jpg") + .nickname("자유로운영혼") + .viewCount(51) + .boardCreatedAt(boardCreatedAt) + .modifiedAt(null) + .build(); + + given(boardService.getBoardDetail(boardId)).willReturn(detailResponse); + + // when + ResponseEntity response = + boardController.getBoardDetail(boardId, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글 상세 조회에 성공했습니다.", response.getBody().getMessage()); + assertEquals(boardId, response.getBody().getBoardId()); + assertEquals("저희 강아지 자랑합니다!", response.getBody().getBoardTitle()); + assertEquals("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?", response.getBody().getBoardContent()); + assertEquals("https://example.com/images/bori.jpg", response.getBody().getImageFileUrl()); + assertEquals("자유로운영혼", response.getBody().getNickname()); + assertEquals(51, response.getBody().getViewCount()); + assertEquals(boardCreatedAt, response.getBody().getBoardCreatedAt()); + assertNull(response.getBody().getModifiedAt()); + + verify(boardService).getBoardDetail(boardId); + + log.info("이미지가 있는 게시글 상세 조회 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 게시글에 이미지가 연결되어 있지 않아도 + * {@code imageFileUrl}이 null인 정상 응답을 반환하는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 상세 조회 성공: 이미지가 없으면 imageFileUrl은 null이다.") + void getBoardDetail_Success_WithoutImage() { + log.info("이미지가 없는 게시글 상세 조회 성공 테스트를 시작합니다."); + + // given + BoardController boardController = new BoardController(boardService); + + UUID userId = UUID.randomUUID(); + Long boardId = 124L; + LocalDateTime boardCreatedAt = LocalDateTime.of(2025, 10, 6, 10, 0); + + UserDetails userDetails = User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + + BoardDetailResponse detailResponse = BoardDetailResponse.builder() + .message("게시글 상세 조회에 성공했습니다.") + .boardId(boardId) + .boardTitle("이미지 없는 게시글") + .boardContent("이미지 없이 작성된 게시글입니다.") + .imageFileUrl(null) + .nickname("자유로운영혼") + .viewCount(3) + .boardCreatedAt(boardCreatedAt) + .modifiedAt(null) + .build(); + + given(boardService.getBoardDetail(boardId)).willReturn(detailResponse); + + // when + ResponseEntity response = + boardController.getBoardDetail(boardId, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(boardId, response.getBody().getBoardId()); + assertNull(response.getBody().getImageFileUrl()); + + verify(boardService).getBoardDetail(boardId); + + log.info("이미지가 없는 게시글 상세 조회 성공 테스트가 통과되었습니다."); } } \ No newline at end of file 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 32707d2..9b84219 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -1,33 +1,40 @@ package com.dodo.backend.board.service; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; 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.exception.BoardErrorCode; +import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.repository.ImageFileRepository; 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.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.*; /** * {@link BoardService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. + *

+ * 게시글 생성, 게시글 상세 조회, 이미지 URL 저장 및 조회 흐름을 + * Mock 객체 기반으로 검증합니다. + *

*/ @Slf4j @ExtendWith(MockitoExtension.class) @@ -39,27 +46,31 @@ class BoardServiceTest { @Mock private BoardRepository boardRepository; + @Mock + private ImageFileRepository imageFileRepository; + @Mock private UserService userService; /** - * 게시글 작성 요청 시 작성자를 조회하고 게시글을 저장한 뒤 게시글 ID를 반환하는지 검증합니다. + * 이미지 URL이 포함된 게시글 작성 성공 시나리오를 테스트합니다. + *

+ * 게시글이 정상적으로 저장된 뒤, 요청에 포함된 이미지 URL 목록이 + * {@link ImageFileRepository#saveAll(Iterable)}을 통해 저장되는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 작성 성공: 게시글이 정상적으로 저장되고 게시글 ID를 반환한다.") - void createBoard_Success() { - log.info("테스트 시작: 게시글 작성 성공"); + @DisplayName("게시글 작성 성공: 이미지가 있으면 image_file도 저장한다.") + void createBoard_Success_WithImages() { + log.info("이미지가 포함된 게시글 작성 성공 테스트를 시작합니다."); // given UUID userId = UUID.randomUUID(); BoardCreateRequest request = BoardCreateRequest.builder() - .boardTitle("저희 강아지 자랑합니다!") - .boardContent("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") - .imageFileUrls(List.of( - "https://example.com/image1.jpg", - "https://example.com/image2.jpg" - )) + .boardTitle("제목") + .boardContent("내용") + .imageFileUrls(List.of("url1", "url2")) .build(); User user = mock(User.class); @@ -67,8 +78,8 @@ void createBoard_Success() { Board savedBoard = Board.builder() .boardId(1L) .user(user) - .boardTitle(request.getBoardTitle()) - .boardContent(request.getBoardContent()) + .boardTitle("제목") + .boardContent("내용") .boardStatus(BoardStatus.PUBLISHED) .boardType(BoardType.FREE) .build(); @@ -80,21 +91,240 @@ void createBoard_Success() { Long boardId = boardService.createBoard(userId, request); // then - assertNotNull(boardId); assertEquals(1L, boardId); - ArgumentCaptor boardCaptor = ArgumentCaptor.forClass(Board.class); - verify(boardRepository).save(boardCaptor.capture()); verify(userService).getUserById(userId); + verify(boardRepository).save(any(Board.class)); + verify(imageFileRepository).saveAll(anyList()); + + log.info("이미지가 포함된 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 작성 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 {@code imageFileUrls}가 null이어도 + * 게시글은 저장되고 이미지 파일 저장은 호출되지 않아야 합니다. + *

+ */ + @Test + @DisplayName("게시글 작성 성공: 이미지가 없으면 image_file은 저장하지 않는다.") + void createBoard_Success_WithoutImages() { + log.info("이미지가 없는 게시글 작성 성공 테스트를 시작합니다."); + + // given + UUID userId = UUID.randomUUID(); + + BoardCreateRequest request = BoardCreateRequest.builder() + .boardTitle("제목") + .boardContent("내용") + .imageFileUrls(null) + .build(); + + User user = mock(User.class); + + Board savedBoard = Board.builder() + .boardId(2L) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(boardRepository.save(any(Board.class))).willReturn(savedBoard); + + // when + Long boardId = boardService.createBoard(userId, request); + + // then + assertEquals(2L, boardId); + + verify(userService).getUserById(userId); + verify(boardRepository).save(any(Board.class)); + verify(imageFileRepository, never()).saveAll(anyList()); + + log.info("이미지가 없는 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 빈 이미지 URL만 전달된 게시글 작성 성공 시나리오를 테스트합니다. + *

+ * 이미지 URL 목록에 빈 문자열 또는 공백 문자열만 포함된 경우, + * 유효한 이미지가 없는 것으로 판단하여 이미지 파일 저장을 수행하지 않는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 작성 성공: 빈 이미지 URL만 있으면 저장하지 않는다.") + void createBoard_Success_BlankImages() { + log.info("빈 이미지 URL만 포함된 게시글 작성 성공 테스트를 시작합니다."); + + // given + UUID userId = UUID.randomUUID(); + + BoardCreateRequest request = BoardCreateRequest.builder() + .boardTitle("제목") + .boardContent("내용") + .imageFileUrls(List.of("", " ")) + .build(); + + User user = mock(User.class); + + Board savedBoard = Board.builder() + .boardId(3L) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(boardRepository.save(any(Board.class))).willReturn(savedBoard); + + // when + Long boardId = boardService.createBoard(userId, request); + + // then + assertEquals(3L, boardId); + + verify(userService).getUserById(userId); + verify(boardRepository).save(any(Board.class)); + verify(imageFileRepository, never()).saveAll(anyList()); + + log.info("빈 이미지 URL만 포함된 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 있는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 게시글이 존재하고 연결된 이미지가 있을 때, + * 대표 이미지 URL이 상세 응답 DTO에 포함되는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 상세 조회 성공: 이미지가 있으면 imageFileUrl을 반환한다.") + void getBoardDetail_Success_WithImage() { + log.info("이미지가 있는 게시글 상세 조회 성공 테스트를 시작합니다."); + + // given + Long boardId = 1L; + LocalDateTime time = LocalDateTime.now(); + + User user = mock(User.class); + given(user.getNickname()).willReturn("닉네임"); + + Board board = Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .viewCount(10) + .boardCreatedAt(time) + .modifiedAt(null) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + ImageFile imageFile = ImageFile.builder() + .imageFileUrl("image-url") + .size(0L) + .originalFilename("board-image") + .board(board) + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + given(imageFileRepository.findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId)) + .willReturn(Optional.of(imageFile)); + + // when + BoardDetailResponse response = boardService.getBoardDetail(boardId); + + // then + assertNotNull(response); + assertEquals("image-url", response.getImageFileUrl()); + + verify(boardRepository).findById(boardId); + verify(imageFileRepository).findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId); + + log.info("이미지가 있는 게시글 상세 조회 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 게시글에 연결된 이미지가 없을 경우, + * 상세 응답의 {@code imageFileUrl}이 null로 반환되는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 상세 조회 성공: 이미지가 없으면 null을 반환한다.") + void getBoardDetail_Success_WithoutImage() { + log.info("이미지가 없는 게시글 상세 조회 성공 테스트를 시작합니다."); + + // given + Long boardId = 2L; + LocalDateTime time = LocalDateTime.now(); + + User user = mock(User.class); + given(user.getNickname()).willReturn("닉네임"); + + Board board = Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .viewCount(10) + .boardCreatedAt(time) + .modifiedAt(null) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + given(imageFileRepository.findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId)) + .willReturn(Optional.empty()); + + // when + BoardDetailResponse response = boardService.getBoardDetail(boardId); + + // then + assertNotNull(response); + assertNull(response.getImageFileUrl()); + + verify(boardRepository).findById(boardId); + verify(imageFileRepository).findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId); + + log.info("이미지가 없는 게시글 상세 조회 성공 테스트가 통과되었습니다."); + } + + /** + * 존재하지 않는 게시글 상세 조회 실패 시나리오를 테스트합니다. + *

+ * 요청한 게시글 ID에 해당하는 게시글이 존재하지 않는 경우, + * {@link BoardErrorCode#BOARD_NOT_FOUND} 예외가 발생하는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 조회 실패: 존재하지 않는 게시글이면 예외가 발생한다.") + void getBoardDetail_Fail_BoardNotFound() { + log.info("존재하지 않는 게시글 상세 조회 실패 테스트를 시작합니다."); + + // given + Long boardId = 999L; + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when + BoardException exception = assertThrows(BoardException.class, + () -> boardService.getBoardDetail(boardId)); + + // then + assertEquals(BoardErrorCode.BOARD_NOT_FOUND, exception.getErrorCode()); - Board board = boardCaptor.getValue(); - assertEquals(user, board.getUser()); - assertEquals("저희 강아지 자랑합니다!", board.getBoardTitle()); - assertEquals("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?", board.getBoardContent()); - assertEquals(BoardStatus.PUBLISHED, board.getBoardStatus()); - assertEquals(BoardType.FREE, board.getBoardType()); - assertEquals(0, board.getViewCount()); + verify(boardRepository).findById(boardId); + verify(imageFileRepository, never()).findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(anyLong()); - log.info("테스트 종료: 게시글 작성 성공 검증 완료"); + log.info("존재하지 않는 게시글 상세 조회 실패 테스트가 통과되었습니다."); } } \ No newline at end of file diff --git a/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java b/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java index 33c9feb..d383c15 100644 --- a/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java +++ b/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java @@ -4,9 +4,10 @@ import com.cloudinary.Uploader; import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.exception.ImageFileException; +import com.dodo.backend.imagefile.mapper.ImageFileMapper; import com.dodo.backend.imagefile.repository.ImageFileRepository; import com.dodo.backend.pet.entity.Pet; -import com.dodo.backend.user.exception.UserException; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,6 +47,9 @@ class ImageFileServiceTest { @Mock private ImageFileRepository imageFileRepository; + @Mock + private ImageFileMapper imageFileMapper; + @Mock private Cloudinary cloudinary; @@ -123,6 +127,36 @@ void getProfileUrlsByPetIds_EmptyInput() { log.info("테스트 종료: 프로필 이미지 조회 (빈 리스트)"); } + @Test + @DisplayName("펫 프로필 이미지 저장 성공: 이미지 URL을 ImageFile 엔티티로 저장한다.") + void savePetProfileImage_Success() { + // given + Pet pet = Pet.builder().build(); + String imageFileUrl = "https://example.com/images/bori.jpg"; + + // when + imageFileService.savePetProfileImage(pet, imageFileUrl); + + // then + verify(imageFileRepository).save(any(ImageFile.class)); + } + + @Test + @DisplayName("펫 프로필 이미지 수정 성공: 기존 이미지 URL을 mapper로 수정한다.") + void updatePetProfileImage_Success() { + // given + Pet pet = Pet.builder().petId(1L).build(); + String imageFileUrl = "https://example.com/images/bori.jpg"; + given(imageFileMapper.updatePetProfileImage(1L, imageFileUrl, "bori.jpg")).willReturn(1); + + // when + imageFileService.updatePetProfileImage(pet, imageFileUrl); + + // then + verify(imageFileMapper).updatePetProfileImage(1L, imageFileUrl, "bori.jpg"); + verify(imageFileRepository, never()).save(any(ImageFile.class)); + } + @Test @DisplayName("이미지 업로드 성공: 파일 목록 업로드 후 URL 목록을 반환한다.") void uploadImages_Success() throws Exception { @@ -153,7 +187,7 @@ void uploadImages_Fail_InvalidFileType() { MockMultipartFile file = new MockMultipartFile("files", "a.txt", "text/plain", "text".getBytes()); // when - UserException exception = assertThrows(UserException.class, () -> + ImageFileException exception = assertThrows(ImageFileException.class, () -> imageFileService.uploadImages(List.of(file)) ); diff --git a/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java b/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java index 81613fb..ed00f88 100644 --- a/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java +++ b/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java @@ -90,6 +90,7 @@ void registerPet_Success() { .age(3) .birth(LocalDateTime.now()) .registrationNumber("1234567890") + .imageFileUrl("https://example.com/images/bori.jpg") .sex("MALE") .deviceId("DEV_123") .build(); @@ -113,6 +114,7 @@ void registerPet_Success() { verify(userPetService, times(1)).existsUser(userId); verify(petRepository, times(1)).save(any(Pet.class)); + verify(imageFileService, times(1)).savePetProfileImage(savedPet, request.getImageFileUrl()); verify(userPetService, times(1)).registerUserPet(userId, savedPet, RegistrationStatus.APPROVED); log.info("펫 등록 성공 테스트가 통과되었습니다."); } @@ -225,6 +227,7 @@ void updatePet_Success() { PetRequest.PetUpdateRequest request = PetRequest.PetUpdateRequest.builder() .petName("새로운초코") .registrationNumber("NEW-999") + .imageFileUrl("https://example.com/images/choco.jpg") .sex("FEMALE") .age(5) .build(); @@ -244,6 +247,7 @@ void updatePet_Success() { assertEquals("새로운초코", response.getPetName()); verify(petMapper, times(1)).updatePetProfileInfo(request, petId); + verify(imageFileService, times(1)).updatePetProfileImage(existingPet, request.getImageFileUrl()); log.info("펫 수정 성공 테스트가 통과되었습니다."); }