From 35250c2a823991a2dfe8fbdb2d981c147c3c508f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:52:52 +0900 Subject: [PATCH 01/20] =?UTF-8?q?[chore/#528]=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=85=EC=8B=9C=20(#529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cockple/demo/domain/member/controller/MemberController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 7e4bd68b4..70a5a53cf 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -53,6 +53,7 @@ public ResponseEntity login(@RequestBody @Valid KakaoLogi .path("/") .maxAge(Duration.ofDays(7)) .sameSite("None") + .domain(".cockple.store") .build() ; From 645a2678bce8a228f1b3a6fb2de26ca126ad26c5 Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:08:20 +0900 Subject: [PATCH 02/20] =?UTF-8?q?[chore/#531]=20Nginx=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95=20(#532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Nginx 프로토콜 설정을 Cloudflare에서 전달받아 설정하도록 변경 * chore: Nginx 웹소켓 타임아웃의 기본값이 1분이므로 60분으로 변경 * chore: Nginx gzip 설정을 통해 json 응답은 압축해서 보내도록 변경 --- nginx/conf.d/prod.conf | 7 +++++-- nginx/conf.d/staging.conf | 3 +++ nginx/nginx.conf | 4 ++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/nginx/conf.d/prod.conf b/nginx/conf.d/prod.conf index 6630299d4..635b2a3cf 100644 --- a/nginx/conf.d/prod.conf +++ b/nginx/conf.d/prod.conf @@ -10,7 +10,10 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; } location / { @@ -18,6 +21,6 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } } diff --git a/nginx/conf.d/staging.conf b/nginx/conf.d/staging.conf index e050fc273..dc016b844 100644 --- a/nginx/conf.d/staging.conf +++ b/nginx/conf.d/staging.conf @@ -11,6 +11,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; } location / { diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2ce913f67..8f66e30c5 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -21,5 +21,9 @@ http { keepalive_timeout 65; client_max_body_size 30M; + gzip on; + gzip_types application/json application/javascript text/plain text/css; + gzip_min_length 256; + include /etc/nginx/conf.d/*.conf; } From 764ef6f2b1303ff53f468429d8d06b76d39f212e Mon Sep 17 00:00:00 2001 From: Yumin Kwon Date: Sun, 8 Mar 2026 16:21:13 +0900 Subject: [PATCH 03/20] =?UTF-8?q?[Fix/#521]=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80/=ED=8C=8C=EC=9D=BC=20API=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refact: image -> file로 통합 * refact: S3 -> GCS로 예외 코드 명칭 변경 * refact: 채팅 이미지 업로드/다운로드 코드 제거 * refact: 채팅 이미지 관련 코드 수정 * feat: 채팅 파일 통합테스트 * feat: 채팅 파일 단위테스트 * feat: 파일 도메인 통합테스트 * feat: 파일 도메인 단위테스트 * fix: dto의 필드명은 바뀌지 않도록 롤뱁 * chore: 사용하지 않는 ChatImageRepository 제거 * test: 채팅 도메인 통합 테스트 및 단위 테스트에 이미지 필드 테스트 추가 * fix: 기존 images dto 유지를 위해 필드명 롤백 * fix: 기존 필드명이 imageOrder가 아닌 imgOrder였으므로 롤백 --------- Co-authored-by: dmori --- .../bookmark/converter/BookmarkConverter.java | 1 - .../service/BookmarkQueryService.java | 6 +- .../chat/controller/ChatController.java | 29 --- .../domain/chat/converter/ChatConverter.java | 28 +- .../demo/domain/chat/domain/ChatMessage.java | 20 +- .../domain/chat/domain/ChatMessageFile.java | 25 +- .../domain/chat/domain/ChatMessageImg.java | 62 ----- .../demo/domain/chat/dto/ChatCommonDTO.java | 4 +- .../demo/domain/chat/dto/ChatMessageDTO.java | 2 +- .../domain/chat/dto/ChatRoomDetailDTO.java | 2 +- .../domain/chat/dto/WebSocketMessageDTO.java | 23 +- .../domain/chat/events/ChatEventListener.java | 2 +- .../chat/events/ChatMessageSendEvent.java | 5 +- .../chat/handler/ChatWebSocketHandler.java | 4 +- .../chat/repository/ChatImageRepository.java | 7 - .../repository/ChatMessageRepository.java | 6 +- .../chat/service/ChatFileServiceImpl.java | 6 +- .../domain/chat/service/ChatImageService.java | 10 - .../chat/service/ChatImageServiceImpl.java | 116 --------- .../domain/chat/service/ChatProcessor.java | 32 +-- .../chat/service/ChatQueryServiceImpl.java | 10 +- .../domain/chat/service/ChatValidator.java | 10 +- .../service/websocket/ChatSendService.java | 52 +--- .../service/ContestCommandServiceImpl.java | 6 +- .../service/ContestQueryServiceImpl.java | 8 +- .../exercise/converter/ExerciseConverter.java | 8 +- .../file/controller/FileController.java | 42 +++ .../{image => file}/dto/FileUploadDTO.java | 2 +- .../domain/file/exception/GcsErrorCode.java | 34 +++ .../domain/file/exception/GcsException.java | 9 + .../service/FileService.java} | 240 ++++++++---------- .../image/controller/ImgController.java | 52 ---- .../demo/domain/image/dto/ImageUploadDTO.java | 14 - .../domain/image/exception/S3ErrorCode.java | 36 --- .../domain/image/exception/S3Exception.java | 9 - .../member/service/MemberCommandService.java | 6 +- .../member/service/MemberQueryService.java | 6 +- .../service/NotificationQueryService.java | 8 +- .../party/converter/PartyConverter.java | 6 +- .../party/service/PartyQueryServiceImpl.java | 8 +- .../chat/integration/ChatIntegrationTest.java | 155 ++++++++++- .../chat/service/ChatFileServiceTest.java | 163 ++++++++++++ .../chat/service/ChatProcessorTest.java | 112 ++++---- .../chat/service/ChatQueryServiceTest.java | 124 ++++++++- .../service/ExerciseLifecycleServiceTest.java | 6 +- .../ExerciseParticipationServiceTest.java | 6 +- .../service/ExerciseQueryServiceTest.java | 6 +- .../file/integration/FileIntegrationTest.java | 157 ++++++++++++ .../domain/file/service/FileServiceTest.java | 171 +++++++++++++ .../service/MemberCommandServiceTest.java | 4 +- .../demo/support/fixture/ChatFixture.java | 13 + 51 files changed, 1156 insertions(+), 717 deletions(-) delete mode 100644 src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java delete mode 100644 src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java delete mode 100644 src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java delete mode 100644 src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/controller/FileController.java rename src/main/java/umc/cockple/demo/domain/{image => file}/dto/FileUploadDTO.java (85%) create mode 100644 src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java create mode 100644 src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java rename src/main/java/umc/cockple/demo/domain/{image/service/ImageService.java => file/service/FileService.java} (61%) delete mode 100644 src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java delete mode 100644 src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java delete mode 100644 src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java delete mode 100644 src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java create mode 100644 src/test/java/umc/cockple/demo/domain/chat/service/ChatFileServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/file/integration/FileIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java b/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java index 59bb3c9ed..0d22c4bd5 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/converter/BookmarkConverter.java @@ -7,7 +7,6 @@ import umc.cockple.demo.domain.bookmark.dto.GetAllExerciseBookmarksResponseDTO; import umc.cockple.demo.domain.bookmark.dto.GetAllPartyBookmarkResponseDTO; import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.image.service.ImageService; import umc.cockple.demo.domain.party.domain.Party; import umc.cockple.demo.domain.party.domain.PartyLevel; import umc.cockple.demo.domain.party.enums.ActivityTime; diff --git a/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java b/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java index 25f3b95eb..709ef03f2 100644 --- a/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryService.java @@ -12,7 +12,7 @@ import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; @@ -43,7 +43,7 @@ public class BookmarkQueryService { private final MemberExerciseRepository memberExerciseRepository; private final MemberRepository memberRepository; private final BookmarkConverter bookmarkConverter; - private final ImageService imageService; + private final FileService fileService; public List getAllExerciseBookmarks(Long memberId, BookmarkedExerciseOrderType orderType) { // 회원 조회하기 @@ -133,7 +133,7 @@ private ActivityTime makeActiveTime(Exercise exercise) { private String getImageUrl(PartyImg partyImg) { if (partyImg != null && partyImg.getImgKey() != null && !partyImg.getImgKey().isBlank()) { - return imageService.getUrlFromKey(partyImg.getImgKey()); + return fileService.getUrlFromKey(partyImg.getImgKey()); } return null; } diff --git a/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java b/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java index 9c087c1c7..7ce4fd011 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java +++ b/src/main/java/umc/cockple/demo/domain/chat/controller/ChatController.java @@ -11,7 +11,6 @@ import umc.cockple.demo.domain.chat.dto.*; import umc.cockple.demo.domain.chat.service.ChatCommandService; import umc.cockple.demo.domain.chat.service.ChatFileService; -import umc.cockple.demo.domain.chat.service.ChatImageService; import umc.cockple.demo.domain.chat.service.ChatQueryService; import umc.cockple.demo.global.response.BaseResponse; import umc.cockple.demo.global.response.code.status.CommonSuccessCode; @@ -27,7 +26,6 @@ public class ChatController { private final ChatQueryService chatQueryService; private final ChatCommandService chatCommandService; private final ChatFileService chatFileService; - private final ChatImageService chatImageService; @GetMapping(value = "/parties") @Operation(summary = "모임 채팅방 목록 조회", description = "회원이 자신의 모임 채팅방 목록을 조회합니다.") @@ -139,33 +137,6 @@ public ResponseEntity downloadFile( return chatFileService.downloadFile(fileId, token); } - //TODO: 파일 다운로드 토큰 발급 API와 통합 - @PostMapping("/images/{imageId}/download-token") - @Operation(summary = "채팅 이미지 다운로드 토큰 발급", description = "채팅방에 업로드된 특정 이미지를 다운로드할 수 있는 일회용 토큰을 발급합니다.") - @ApiResponse(responseCode = "200", description = "토큰 발급 성공") - @ApiResponse(responseCode = "403", description = "이미지 접근 권한 없음") - @ApiResponse(responseCode = "404", description = "존재하지 않는 이미지") - public BaseResponse issueImageDownloadToken( - @PathVariable Long imageId - ) { - Long memberId = SecurityUtil.getCurrentMemberId(); - ChatDownloadTokenDTO.Response response = chatImageService.issueDownloadToken(imageId, memberId); - return BaseResponse.success(CommonSuccessCode.OK, response); - } - - //TODO: 파일 다운로드 API와 통합 - @GetMapping("/images/{imageId}/download") - @Operation(summary = "채팅 이미지 다운로드", description = "발급받은 다운로드 토큰을 검증하고, 유효할 경우 실제 이미지 데이터를 반환합니다.") - @ApiResponse(responseCode = "200", description = "이미지 다운로드 성공") - @ApiResponse(responseCode = "403", description = "유효하지 않거나 만료된 토큰") - @ApiResponse(responseCode = "404", description = "존재하지 않는 이미지") - public ResponseEntity downloadImage( - @PathVariable Long imageId, - @RequestParam String token - ) { - return chatImageService.downloadImage(imageId, token); - } - @GetMapping("/parties/{partyId}") @Operation(summary = "모임 채팅방 ID 조회") @ApiResponse(responseCode = "200", description = "채팅방 ID 조회 성공") diff --git a/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java b/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java index cfd6ab05d..c1b93d071 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java +++ b/src/main/java/umc/cockple/demo/domain/chat/converter/ChatConverter.java @@ -118,16 +118,14 @@ public List toMemberInfo(List images, - List files, + List files, ChatMessage savedMessage, Member sender, String senderProfileImageUrl, int unreadCount) { return WebSocketMessageDTO.MessageResponse.builder() .type(WebSocketMessageType.SEND) .chatRoomId(chatRoomId) .messageId(savedMessage.getId()) .content(content) - .images(images) - .files(files) + .images(files) .senderId(sender.getId()) .senderName(sender.getMemberName()) .senderProfileImageUrl(senderProfileImageUrl) @@ -171,7 +169,7 @@ public ChatRoomDetailDTO.ChatRoomInfo toChatRoomDetailChatRoomInfo( public ChatCommonDTO.MessageInfo toCommonMessageInfo( ChatMessage message, String senderProfileImageUrl, - List processedImages, + List processedFiles, boolean isMyMessage, boolean isSenderWithdrawn) { @@ -183,21 +181,21 @@ public ChatCommonDTO.MessageInfo toCommonMessageInfo( .isSenderWithdrawn(isSenderWithdrawn) .content(message.getContent()) .messageType(message.getType()) - .images(processedImages) + .images(processedFiles) .timestamp(message.getCreatedAt()) .isMyMessage(isMyMessage) .build(); } - public ChatCommonDTO.ImageInfo toImageInfo(ChatMessageImg img, String imageUrl) { - return ChatCommonDTO.ImageInfo.builder() - .imageId(img.getId()) - .imageUrl(imageUrl) - .imgOrder(img.getImgOrder()) - .isEmoji(img.getIsEmoji()) - .originalFileName(img.getOriginalFileName()) - .fileSize(img.getFileSize()) - .fileType(img.getFileType()) + public ChatCommonDTO.FileInfo toFileInfo(ChatMessageFile file, String fileUrl) { + return ChatCommonDTO.FileInfo.builder() + .imageId(file.getId()) + .imageUrl(fileUrl) + .imgOrder(file.getFileOrder()) + .isEmoji(file.getIsEmoji()) + .originalFileName(file.getOriginalFileName()) + .fileSize(file.getFileSize()) + .fileType(file.getFileType()) .build(); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java index 20b9c7999..d74602837 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java +++ b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessage.java @@ -38,10 +38,6 @@ public class ChatMessage extends BaseEntity { @Column(nullable = false) private Boolean isDeleted; - @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List chatMessageImgs = new ArrayList<>(); - @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List chatMessageFiles = new ArrayList<>(); @@ -61,11 +57,11 @@ public String getDisplayContent() { return this.content; } - if (this.chatMessageImgs != null && !this.chatMessageImgs.isEmpty()) { - ChatMessageImg firstImg = this.chatMessageImgs.get(0); - int count = this.chatMessageImgs.size(); + if (this.chatMessageFiles != null && !this.chatMessageFiles.isEmpty()) { + ChatMessageFile firstFile = this.chatMessageFiles.get(0); + int count = this.chatMessageFiles.size(); - if (firstImg.getIsEmoji()) { + if (firstFile.getIsEmoji()) { return "이모티콘을 보냈습니다."; } else { return count > 1 ? @@ -74,14 +70,6 @@ public String getDisplayContent() { } } - // 파일이 있는 경우 - if (this.chatMessageFiles != null && !this.chatMessageFiles.isEmpty()) { - int fileCount = this.chatMessageFiles.size(); - return fileCount > 1 ? - String.format("파일 %d개를 보냈습니다.", fileCount) : - "파일을 보냈습니다."; - } - return "메시지"; } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java index 53f27751c..d429ea9c6 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java +++ b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageFile.java @@ -6,9 +6,9 @@ @Entity @Getter -@Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@Builder public class ChatMessageFile extends BaseEntity { @Id @@ -22,6 +22,9 @@ public class ChatMessageFile extends BaseEntity { @Column(nullable = false) private String fileKey; + @Column(nullable = false) + private Integer fileOrder; + @Column(nullable = false) private String originalFileName; @@ -29,13 +32,31 @@ public class ChatMessageFile extends BaseEntity { private String fileType; - public static ChatMessageFile create(ChatMessage message, String originalFileName, String fileKey, Long fileSize, String fileType) { + @Column(nullable = false) + @Builder.Default + private Boolean isEmoji = false; + + public static ChatMessageFile create(ChatMessage message, String fileKey, Integer fileOrder, String originalFileName, Long fileSize, String fileType) { + boolean isEmoji = isEmojiFileName(originalFileName); + return ChatMessageFile.builder() .chatMessage(message) .fileKey(fileKey) + .fileOrder(fileOrder) .originalFileName(originalFileName) .fileSize(fileSize) .fileType(fileType) + .isEmoji(isEmoji) .build(); } + + private static boolean isEmojiFileName(String originalFileName) { + if (originalFileName == null) { + return false; + } + return "emoji.png".equalsIgnoreCase(originalFileName.trim()); + } } + + + diff --git a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java b/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java deleted file mode 100644 index bf2e28299..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/domain/ChatMessageImg.java +++ /dev/null @@ -1,62 +0,0 @@ -package umc.cockple.demo.domain.chat.domain; - -import jakarta.persistence.*; -import lombok.*; -import umc.cockple.demo.global.common.BaseEntity; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class ChatMessageImg extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "chat_message_id") - private ChatMessage chatMessage; - - @Column(nullable = false) - private String imgKey; - - @Column(nullable = false) - private Integer imgOrder; - - @Column(nullable = false) - private String originalFileName; - - private Long fileSize; - - private String fileType; - - @Column(nullable = false) - @Builder.Default - private Boolean isEmoji = false; - - public static ChatMessageImg create(ChatMessage message, String imgKey, Integer imgOrder, String originalFileName, Long fileSize, String fileType) { - boolean isEmoji = isEmojiFileName(originalFileName); - - return ChatMessageImg.builder() - .chatMessage(message) - .imgKey(imgKey) - .imgOrder(imgOrder) - .originalFileName(originalFileName) - .fileSize(fileSize) - .fileType(fileType) - .isEmoji(isEmoji) - .build(); - } - - private static boolean isEmojiFileName(String originalFileName) { - if (originalFileName == null) { - return false; - } - return "emoji.png".equalsIgnoreCase(originalFileName.trim()); - } -} - - - diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java index 75e0851c5..e58dc5bde 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java +++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatCommonDTO.java @@ -17,14 +17,14 @@ public record MessageInfo( boolean isSenderWithdrawn, String content, MessageType messageType, - List images, + List images, LocalDateTime timestamp, boolean isMyMessage ) { } @Builder - public record ImageInfo( + public record FileInfo( Long imageId, String imageUrl, Integer imgOrder, diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java index cd00d5d4f..0852c3d35 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java +++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatMessageDTO.java @@ -26,7 +26,7 @@ public record MessageInfo( boolean isSenderWithdrawn, String content, MessageType messageType, - List images, + List images, LocalDateTime timestamp, boolean isMyMessage ) { diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java index 34176c8fe..60ebeab33 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java +++ b/src/main/java/umc/cockple/demo/domain/chat/dto/ChatRoomDetailDTO.java @@ -38,7 +38,7 @@ public record MessageInfo( boolean isSenderWithdrawn, String content, MessageType messageType, - List images, + List images, LocalDateTime timestamp, boolean isMyMessage ) { diff --git a/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java b/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java index 138002627..1787a00c7 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java +++ b/src/main/java/umc/cockple/demo/domain/chat/dto/WebSocketMessageDTO.java @@ -13,21 +13,11 @@ public record Request( Long chatRoomId, List memberRooms, String content, - List files, - List images, + List images, Long lastReadMessageId ) { @Builder public record FileInfo( - String fileKey, - String originalFileName, - Long fileSize, - String fileType - ) { - } - - @Builder - public record ImageInfo( String imgKey, Integer imgOrder, String originalFileName, @@ -53,22 +43,13 @@ public record MessageResponse( Long chatRoomId, Long messageId, String content, - List files, - List images, + List images, Long senderId, String senderName, String senderProfileImageUrl, LocalDateTime timestamp, Integer unreadCount ) { - @Builder - public record FileInfo( - Long fileId, - String originalFileName, - Long fileSize, - String fileType - ) { - } } @Builder diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java index 143c81e06..983db218d 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatEventListener.java @@ -36,7 +36,7 @@ public void handleChatMessageSend(ChatMessageSendEvent event) { event.chatRoomId(), event.senderId()); try { chatSendService - .sendMessage(event.chatRoomId(), event.content(), event.files(), event.images(), event.senderId()); + .sendMessage(event.chatRoomId(), event.content(), event.files(), event.senderId()); } catch (Exception e) { log.error("메시지 전송 이벤트 처리 중 오류 발생", e); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java index 84192b2fd..e90e3f2d8 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatMessageSendEvent.java @@ -2,7 +2,6 @@ import lombok.Builder; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.FileInfo; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.ImageInfo; import umc.cockple.demo.domain.chat.enums.MessageType; import java.util.List; @@ -12,17 +11,15 @@ public record ChatMessageSendEvent( Long chatRoomId, String content, List files, - List images, Long senderId, MessageType messageType ) { public static ChatMessageSendEvent create( - Long chatRoomId, String content, List files, List images, Long senderId) { + Long chatRoomId, String content, List files, Long senderId) { return ChatMessageSendEvent.builder() .chatRoomId(chatRoomId) .content(content) .files(files) - .images(images) .senderId(senderId) .messageType(MessageType.TEXT) .build(); diff --git a/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java b/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java index 21f4fb80e..759614709 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java +++ b/src/main/java/umc/cockple/demo/domain/chat/handler/ChatWebSocketHandler.java @@ -130,11 +130,11 @@ public void handleTransportError(WebSocketSession session, Throwable exception) private void handleSendMessage(WebSocketSession session, WebSocketMessageDTO.Request request, Long memberId) { try { chatValidator.validateSendRequest( - request.chatRoomId(), request.content(), request.files(), request.images(), memberId); + request.chatRoomId(), request.content(), request.images(), memberId); ChatMessageSendEvent sendEvent = ChatMessageSendEvent.create( - request.chatRoomId(), request.content(), request.files(), request.images(), memberId); + request.chatRoomId(), request.content(), request.images(), memberId); eventPublisher.publishEvent(sendEvent); } catch (ChatException e) { diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java deleted file mode 100644 index c26bb2572..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatImageRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package umc.cockple.demo.domain.chat.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import umc.cockple.demo.domain.chat.domain.ChatMessageImg; - -public interface ChatImageRepository extends JpaRepository { -} diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java index dd02f9407..6e511a3be 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java @@ -26,19 +26,19 @@ int countUnreadMessages( @Query(""" SELECT m FROM ChatMessage m JOIN FETCH m.sender - LEFT JOIN FETCH m.chatMessageImgs + LEFT JOIN FETCH m.chatMessageFiles WHERE m.chatRoom.id = :chatRoomId AND m.isDeleted = false ORDER BY m.createdAt DESC """) - List findRecentMessagesWithImages( + List findRecentMessagesWithFiles( @Param("chatRoomId") Long chatRoomId, Pageable pageable); @Query(""" SELECT m FROM ChatMessage m JOIN FETCH m.sender - LEFT JOIN FETCH m.chatMessageImgs + LEFT JOIN FETCH m.chatMessageFiles WHERE m.chatRoom.id = :chatRoomId AND m.id < :cursor ORDER BY m.createdAt DESC diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java index 0c0130099..6ba2ba251 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatFileServiceImpl.java @@ -20,7 +20,7 @@ import umc.cockple.demo.domain.chat.repository.ChatFileRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.DownloadTokenRepository; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; @@ -35,7 +35,7 @@ public class ChatFileServiceImpl implements ChatFileService{ private final DownloadTokenRepository downloadTokenRepository; private final ChatConverter chatConverter; private final ChatRoomMemberRepository chatRoomMemberRepository; - private final ImageService imageService; + private final FileService fileService; private static final int TOKEN_VALIDITY_SECONDS = 180; @Override @@ -66,7 +66,7 @@ public ResponseEntity downloadFile(Long fileId, String token) { ChatMessageFile chatFile = findChatFileOrThrow(fileId); //GCS에서 파일 객체 직접 가져오기 - Blob blob = imageService.downloadFile(chatFile.getFileKey()); + Blob blob = fileService.downloadFile(chatFile.getFileKey()); ResponseEntity responseEntity = createDownloadResponseEntity(chatFile, blob); log.info("파일 다운로드 완료 - fileName: {}", chatFile.getOriginalFileName()); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java deleted file mode 100644 index 012113853..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageService.java +++ /dev/null @@ -1,10 +0,0 @@ -package umc.cockple.demo.domain.chat.service; - -import org.springframework.core.io.Resource; -import org.springframework.http.ResponseEntity; -import umc.cockple.demo.domain.chat.dto.ChatDownloadTokenDTO; - -public interface ChatImageService { - ChatDownloadTokenDTO.Response issueDownloadToken(Long imageId, Long memberId); - ResponseEntity downloadImage(Long imageId, String token); -} \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java deleted file mode 100644 index e7d778d0b..000000000 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatImageServiceImpl.java +++ /dev/null @@ -1,116 +0,0 @@ -package umc.cockple.demo.domain.chat.service; - -import com.google.cloud.storage.Blob; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.InputStreamResource; -import org.springframework.core.io.Resource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.chat.converter.ChatConverter; -import umc.cockple.demo.domain.chat.domain.ChatMessageImg; -import umc.cockple.demo.domain.chat.domain.DownloadToken; -import umc.cockple.demo.domain.chat.dto.ChatDownloadTokenDTO; -import umc.cockple.demo.domain.chat.exception.ChatErrorCode; -import umc.cockple.demo.domain.chat.exception.ChatException; -import umc.cockple.demo.domain.chat.repository.ChatImageRepository; -import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; -import umc.cockple.demo.domain.chat.repository.DownloadTokenRepository; -import umc.cockple.demo.domain.image.service.ImageService; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class ChatImageServiceImpl implements ChatImageService{ - - private final ChatImageRepository chatImageRepository; - private final DownloadTokenRepository downloadTokenRepository; - private final ChatConverter chatConverter; - private final ChatRoomMemberRepository chatRoomMemberRepository; - private final ImageService imageService; - private static final int TOKEN_VALIDITY_SECONDS = 180; - - @Override - public ChatDownloadTokenDTO.Response issueDownloadToken(Long fileId, Long memberId) { - log.info("다운로드 토큰 발급 시작 - fileId: {}, memberId: {}", fileId, memberId); - - //이미지 파일 조회 - ChatMessageImg chatImage = findChatImageOrThrow(fileId); - - //사용자 검증 - validateMemberPermission(chatImage, memberId); - - //다운로드 토큰 생성 및 저장 - DownloadToken downloadToken = DownloadToken.create(fileId, memberId, TOKEN_VALIDITY_SECONDS); - downloadTokenRepository.save(downloadToken); - - log.info("다운로드 토큰 발급 완료 - fileId: {}", fileId); - return chatConverter.toDownloadTokenResponse(downloadToken, TOKEN_VALIDITY_SECONDS); - } - - @Override - public ResponseEntity downloadImage(Long imageId, String token) { - log.info("이미지 다운로드 시작 - imageId: {}", imageId); - - //토큰 검증 - validateToken(imageId, token); - //채팅 파일 조회 - ChatMessageImg chatImage = findChatImageOrThrow(imageId); - - //GCS에서 파일 객체 직접 가져오기 - Blob blob = imageService.downloadFile(chatImage.getImgKey()); - ResponseEntity responseEntity = createDownloadResponseEntity(chatImage, blob); - - log.info("이미지 다운로드 완료 - imageName: {}", chatImage.getOriginalFileName()); - return responseEntity; - } - - private ChatMessageImg findChatImageOrThrow(Long imageId) { - return chatImageRepository.findById(imageId) - .orElseThrow(() -> new ChatException(ChatErrorCode.FILE_NOT_FOUND)); - } - - private void validateMemberPermission(ChatMessageImg chatImage, Long memberId) { - Long roomId = chatImage.getChatMessage().getChatRoom().getId(); - if (!chatRoomMemberRepository.existsByChatRoomIdAndMemberId(roomId, memberId)) - throw new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); - } - - private void validateToken(Long ImageId, String tokenValue) { - DownloadToken token = downloadTokenRepository.findByToken(tokenValue) - .orElseThrow(() -> new ChatException(ChatErrorCode.INVALID_DOWNLOAD_TOKEN)); - //토큰 유효성 검증 (만료 시간, 이미지 ID) - if (token.getExpiresAt().isBefore(LocalDateTime.now()) || !token.getFileId().equals(ImageId)) { - throw new ChatException(ChatErrorCode.INVALID_DOWNLOAD_TOKEN); - } - //사용된 토큰 삭제 - downloadTokenRepository.delete(token); - } - - private ResponseEntity createDownloadResponseEntity(ChatMessageImg chatMessageImg, Blob blob) { - //GCS 객체에서 직접 메타데이터를 가져오기 - long contentLength = blob.getSize(); - String contentType = blob.getContentType(); - Resource resource = new InputStreamResource(new java.io.ByteArrayInputStream(blob.getContent())); - - //헤더 생성 - ContentDisposition contentDisposition = ContentDisposition.builder("attachment") - .filename(chatMessageImg.getOriginalFileName(), StandardCharsets.UTF_8) - .build(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentDisposition(contentDisposition); - headers.setContentType(MediaType.parseMediaType(contentType)); - headers.setContentLength(contentLength); - - return ResponseEntity.ok().headers(headers).body(resource); - } -} \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java index c9a7e846e..476fa9ae6 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatProcessor.java @@ -5,9 +5,9 @@ import org.springframework.stereotype.Component; import umc.cockple.demo.domain.chat.converter.ChatConverter; import umc.cockple.demo.domain.chat.domain.ChatMessage; -import umc.cockple.demo.domain.chat.domain.ChatMessageImg; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.dto.ChatCommonDTO; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.ProfileImg; import umc.cockple.demo.domain.member.enums.MemberStatus; @@ -20,7 +20,7 @@ @Slf4j public class ChatProcessor { - private final ImageService imageService; + private final FileService fileService; private final ChatConverter chatConverter; public List processMessages(Long memberId, List recentMessages) { @@ -32,34 +32,34 @@ public List processMessages(Long memberId, List processedImages = processMessageImages(message); + List processedFiles = processMessageFiles(message); boolean isMyMessage = isMyMessage(sender.getId(), memberId); boolean isSenderWithdrawn = sender.getIsActive() == MemberStatus.INACTIVE; - return chatConverter.toCommonMessageInfo(message, senderProfileImageUrl, processedImages, isMyMessage, isSenderWithdrawn); + return chatConverter.toCommonMessageInfo(message, senderProfileImageUrl, processedFiles, isMyMessage, isSenderWithdrawn); } public String generateProfileImageUrl(ProfileImg profileImg) { if (profileImg != null && profileImg.getImgKey() != null && !profileImg.getImgKey().isBlank()) { - return imageService.getUrlFromKey(profileImg.getImgKey()); + return fileService.getUrlFromKey(profileImg.getImgKey()); } return null; } - private List processMessageImages(ChatMessage message) { - return message.getChatMessageImgs().stream() - .sorted(Comparator.comparing(ChatMessageImg::getImgOrder)) - .map(this::processSingleImage) + private List processMessageFiles(ChatMessage message) { + return message.getChatMessageFiles().stream() + .sorted(Comparator.comparing(ChatMessageFile::getFileOrder)) + .map(this::processSingleFile) .toList(); } - private ChatCommonDTO.ImageInfo processSingleImage(ChatMessageImg img) { - String imageUrl = generateImageUrl(img); - return chatConverter.toImageInfo(img, imageUrl); + private ChatCommonDTO.FileInfo processSingleFile(ChatMessageFile file) { + String imageUrl = generateFileUrl(file); + return chatConverter.toFileInfo(file, imageUrl); } - public String generateImageUrl(ChatMessageImg img) { - if (img != null && img.getImgKey() != null && !img.getImgKey().isBlank()) { - return imageService.getUrlFromKey(img.getImgKey()); + public String generateFileUrl(ChatMessageFile file) { + if (file != null && file.getFileKey() != null && !file.getFileKey().isBlank()) { + return fileService.getUrlFromKey(file.getFileKey()); } return null; } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java index 0b2f6b608..a87a3f528 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceImpl.java @@ -20,7 +20,7 @@ import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.ProfileImg; import umc.cockple.demo.domain.member.enums.MemberStatus; @@ -48,7 +48,7 @@ public class ChatQueryServiceImpl implements ChatQueryService { private final MessageReadStatusRepository messageReadStatusRepository; private final ChatConverter chatConverter; - private final ImageService imageService; + private final FileService fileService; private final ChatProcessor chatProcessor; private final ChatRoomListCacheService chatRoomListCacheService; @@ -305,7 +305,7 @@ private ChatRoomDetailDTO.MemberInfo buildMemberInfo(ChatRoomMember chatRoomMemb private String getImageUrl(PartyImg partyImg) { if (partyImg != null && partyImg.getImgKey() != null && !partyImg.getImgKey().isBlank()) { - return imageService.getUrlFromKey(partyImg.getImgKey()); + return fileService.getUrlFromKey(partyImg.getImgKey()); } return null; } @@ -314,7 +314,7 @@ private String getImageUrl(ProfileImg profileImg) { if (profileImg == null || profileImg.getImgKey() == null) { return null; } - return imageService.getUrlFromKey(profileImg.getImgKey()); + return fileService.getUrlFromKey(profileImg.getImgKey()); } // ========== 조회 메서드 ========== @@ -341,7 +341,7 @@ private List findChatRoomMembersWithMemberOrThrow(Long roomId) { } private List findRecentMessagesWithImages(Long roomId, Pageable pageable) { - return chatMessageRepository.findRecentMessagesWithImages(roomId, pageable); + return chatMessageRepository.findRecentMessagesWithFiles(roomId, pageable); } private List findMessagesWithCursor(Long roomId, Long cursor, Pageable pageable) { diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java index fbe80f8ca..4a3fc6e3c 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatValidator.java @@ -4,7 +4,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.FileInfo; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.ImageInfo; import umc.cockple.demo.domain.chat.exception.ChatErrorCode; import umc.cockple.demo.domain.chat.exception.ChatException; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; @@ -20,10 +19,10 @@ public class ChatValidator { private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; - public void validateSendRequest(Long chatRoomId, String content, List files, List images, Long senderId) { + public void validateSendRequest(Long chatRoomId, String content, List files, Long senderId) { validateChatRoom(chatRoomId); validateChatRoomMember(chatRoomId, senderId); - validateMessage(content, files, images); + validateMessage(content, files); } public void validateSubscriptionRequest(Long chatRoomId, Long senderId) { @@ -63,12 +62,11 @@ private void validateChatRoomMember(Long chatRoomId, Long memberId) { throw new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); } - private void validateMessage(String content, List files, List images) { + private void validateMessage(String content, List files) { boolean hasContent = content != null && !content.trim().isEmpty(); boolean hasFiles = files != null && !files.isEmpty(); - boolean hasImages = images != null && !images.isEmpty(); - if (!hasContent && !hasFiles && !hasImages) { + if (!hasContent && !hasFiles) { throw new ChatException(ChatErrorCode.EMPTY_MESSAGE_NOT_ALLOWED); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java index 7ad1ee46e..deaf94f69 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatSendService.java @@ -10,7 +10,6 @@ import umc.cockple.demo.domain.chat.dto.ChatCommonDTO; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO; import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.FileInfo; -import umc.cockple.demo.domain.chat.dto.WebSocketMessageDTO.Request.ImageInfo; import umc.cockple.demo.domain.chat.enums.ChatRoomType; import umc.cockple.demo.domain.chat.enums.MessageType; import umc.cockple.demo.domain.chat.events.ChatRoomListUpdateEvent; @@ -49,7 +48,7 @@ public class ChatSendService { private final ApplicationEventPublisher eventPublisher; - public void sendMessage(Long chatRoomId, String content, List files, List images, Long senderId) { + public void sendMessage(Long chatRoomId, String content, List files, Long senderId) { log.info("메시지 전송 시작 - 채팅방: {}, 발신자: {}", chatRoomId, senderId); ChatRoom chatRoom = findChatRoom(chatRoomId); @@ -59,7 +58,6 @@ public void sendMessage(Long chatRoomId, String content, List files, L ChatMessage chatMessage = ChatMessage.create(chatRoom, sender, content, MessageType.TEXT); attachFiles(chatMessage, files); - attachImages(chatMessage, images); ChatMessage savedMessage = chatMessageRepository.save(chatMessage); log.info("메시지 저장 완료 - 메시지 ID: {}", savedMessage.getId()); @@ -69,14 +67,12 @@ public void sendMessage(Long chatRoomId, String content, List files, L List activeSubscribers = subscriptionService.getActiveSubscribers(chatRoomId); int unreadCount = chatReadService.subscribersToReadStatus(chatRoom.getId(), savedMessage.getId(), activeSubscribers, senderId); - List responseImages = - createResponseImageInfos(savedMessage.getChatMessageImgs()); - List responseFiles = + List responseFiles = createResponseFileInfos(savedMessage.getChatMessageFiles()); log.info("메시지 브로드캐스트 시작 - 채팅방 ID: {}", chatRoomId); WebSocketMessageDTO.MessageResponse response = - chatConverter.toSendMessageResponse(chatRoomId, content, responseImages, responseFiles, savedMessage, sender, profileImageUrl, unreadCount); + chatConverter.toSendMessageResponse(chatRoomId, content, responseFiles, savedMessage, sender, profileImageUrl, unreadCount); subscriptionService.broadcastMessage(chatRoomId, response, senderId); log.info("메시지 브로드캐스트 완료 - 채팅방 ID: {}", chatRoomId); @@ -97,30 +93,18 @@ public void sendSystemMessage(Long partyId, String content) { } // ========== 비즈니스 메서드 ========== - private void attachFiles(ChatMessage message, List files) { + private void attachFiles(ChatMessage message, List files) { if (files != null && !files.isEmpty()) { files.forEach(fileInfo -> { ChatMessageFile messageFile = ChatMessageFile.create( - message, fileInfo.originalFileName(), - fileInfo.fileKey(), fileInfo.fileSize(), fileInfo.fileType() + message, fileInfo.imgKey(), fileInfo.imgOrder(), + fileInfo.originalFileName(), fileInfo.fileSize(), fileInfo.fileType() ); message.getChatMessageFiles().add(messageFile); }); } } - private void attachImages(ChatMessage message, List images) { - if (images != null && !images.isEmpty()) { - images.forEach(imageInfo -> { - ChatMessageImg messageImg = ChatMessageImg.create( - message, imageInfo.imgKey(), imageInfo.imgOrder(), - imageInfo.originalFileName(), imageInfo.fileSize(), imageInfo.fileType() - ); - message.getChatMessageImgs().add(messageImg); - }); - } - } - private void checkFirstMessageInDirect(Long chatRoomId, Long senderId, ChatRoom chatRoom) { if (chatRoom.getType() == ChatRoomType.DIRECT && isFirstMessage(chatRoomId)) { handleFirstDirectMessage(chatRoomId, senderId); @@ -144,26 +128,14 @@ private void handleFirstDirectMessage(Long chatRoomId, Long senderId) { } } - private List createResponseImageInfos( - List savedImages) { - return savedImages.stream() - .map(img -> ChatCommonDTO.ImageInfo.builder() - .imageId(img.getId()) - .imageUrl(chatProcessor.generateImageUrl(img)) - .imgOrder(img.getImgOrder()) - .isEmoji(img.getIsEmoji()) - .originalFileName(img.getOriginalFileName()) - .fileSize(img.getFileSize()) - .fileType(img.getFileType()) - .build()) - .toList(); - } - - private List createResponseFileInfos( + private List createResponseFileInfos( List savedFiles) { return savedFiles.stream() - .map(file -> WebSocketMessageDTO.MessageResponse.FileInfo.builder() - .fileId(file.getId()) + .map(file -> ChatCommonDTO.FileInfo.builder() + .imageId(file.getId()) + .imageUrl(chatProcessor.generateFileUrl(file)) + .imgOrder(file.getFileOrder()) + .isEmoji(file.getIsEmoji()) .originalFileName(file.getOriginalFileName()) .fileSize(file.getFileSize()) .fileType(file.getFileType()) diff --git a/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java index 56a9dcd30..d95e1ccaf 100644 --- a/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/contest/service/ContestCommandServiceImpl.java @@ -14,7 +14,7 @@ import umc.cockple.demo.domain.contest.repository.ContestRepository; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import java.util.List; import java.util.Map; @@ -30,7 +30,7 @@ public class ContestCommandServiceImpl implements ContestCommandService { private final ContestRepository contestRepository; private final MemberRepository memberRepository; private final ContestConverter contestConverter; - private final ImageService imageService; + private final FileService fileService; @@ -142,7 +142,7 @@ private void updateContestImages(Contest contest, List .toList(); for (ContestImg img : imgsToRemove) { - imageService.delete(img.getImgKey()); + fileService.delete(img.getImgKey()); contest.getContestImgs().remove(img); } diff --git a/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java index 890390279..dd5ee0fd6 100644 --- a/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/contest/service/ContestQueryServiceImpl.java @@ -13,7 +13,7 @@ import umc.cockple.demo.domain.contest.exception.ContestException; import umc.cockple.demo.domain.contest.repository.ContestRepository; import umc.cockple.demo.domain.contest.enums.MedalType; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import java.util.Comparator; import java.util.List; @@ -27,7 +27,7 @@ public class ContestQueryServiceImpl implements ContestQueryService { private final ContestRepository contestRepository; private final ContestConverter contestConverter; - private final ImageService imageService; + private final FileService fileService; // 대회 기록 상세 조회 @Override @@ -106,7 +106,7 @@ private List getImageIds(Contest contest) { private List getImageUrls(Contest contest) { return contest.getContestImgs().stream() .sorted(Comparator.comparing(ContestImg::getImgOrder)) - .map(img -> imageService.getUrlFromKey(img.getImgKey())) + .map(img -> fileService.getUrlFromKey(img.getImgKey())) .collect(Collectors.toList()); } @@ -147,6 +147,6 @@ public String getMedalImgUrl(Contest contest) { case BRONZE -> baseKey + "3f9778a5-479a-44cf-bfb0-bea187a839c5.svg"; case NONE -> baseKey + "84e4dd20-7989-4871-954b-7363213b941e.svg"; }; - return imageService.getUrlFromKey(key); + return fileService.getUrlFromKey(key); } } diff --git a/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java b/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java index 26d8340dd..c7a17cad8 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/converter/ExerciseConverter.java @@ -8,7 +8,7 @@ import umc.cockple.demo.domain.exercise.domain.Guest; import umc.cockple.demo.domain.exercise.dto.*; import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.member.domain.MemberExercise; @@ -27,7 +27,7 @@ @RequiredArgsConstructor public class ExerciseConverter { - private final ImageService imageService; + private final FileService fileService; // ========== Command 변환 메서드들 ========== public ExerciseCreateDTO.Command toCreateCommand(ExerciseCreateDTO.Request request) { @@ -496,14 +496,14 @@ private List filterExercisesByWeek(List exercises, LocalDate private String getImageUrl(PartyImg partyImg) { if (partyImg != null && partyImg.getImgKey() != null && !partyImg.getImgKey().isBlank()) { - return imageService.getUrlFromKey(partyImg.getImgKey()); + return fileService.getUrlFromKey(partyImg.getImgKey()); } return null; } private String getImageUrl(ProfileImg profileImg) { if (profileImg != null && profileImg.getImgKey() != null && !profileImg.getImgKey().isBlank()) { - return imageService.getUrlFromKey(profileImg.getImgKey()); + return fileService.getUrlFromKey(profileImg.getImgKey()); } return null; } diff --git a/src/main/java/umc/cockple/demo/domain/file/controller/FileController.java b/src/main/java/umc/cockple/demo/domain/file/controller/FileController.java new file mode 100644 index 000000000..ce0d28761 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/controller/FileController.java @@ -0,0 +1,42 @@ +package umc.cockple.demo.domain.file.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import umc.cockple.demo.domain.file.dto.FileUploadDTO; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.global.enums.DomainType; +import umc.cockple.demo.global.response.BaseResponse; +import umc.cockple.demo.global.response.code.status.CommonSuccessCode; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Validated +@Tag(name = "File", description = "파일 업로드 API") +public class FileController { + + private final FileService fileService; + + @PostMapping(value = "/gcs/upload/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "파일 업로드", description = "GCS에 파일을 업로드하고 파일 URL과 fileKey를 반환합니다.") + public BaseResponse fileUpload(@RequestPart("file") MultipartFile file, + @RequestParam("domainType") DomainType domainType) { + + return BaseResponse.success(CommonSuccessCode.ACCEPTED, fileService.uploadFile(file, domainType)); + } + + @PostMapping(value = "/gcs/upload/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "파일 여러장 업로드", description = "GCS에 파일 여러장을 업로드하고 파일 URL과 fileKey를 반환합니다.") + public BaseResponse> fileUpload(@RequestPart("file") List files, + @RequestParam("domainType") DomainType domainType) { + + return BaseResponse.success(CommonSuccessCode.ACCEPTED, fileService.uploadFiles(files, domainType)); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/image/dto/FileUploadDTO.java b/src/main/java/umc/cockple/demo/domain/file/dto/FileUploadDTO.java similarity index 85% rename from src/main/java/umc/cockple/demo/domain/image/dto/FileUploadDTO.java rename to src/main/java/umc/cockple/demo/domain/file/dto/FileUploadDTO.java index c6df9a8e2..7159e6bf5 100644 --- a/src/main/java/umc/cockple/demo/domain/image/dto/FileUploadDTO.java +++ b/src/main/java/umc/cockple/demo/domain/file/dto/FileUploadDTO.java @@ -1,4 +1,4 @@ -package umc.cockple.demo.domain.image.dto; +package umc.cockple.demo.domain.file.dto; import lombok.Builder; diff --git a/src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java b/src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java new file mode 100644 index 000000000..f8c45bc5e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/exception/GcsErrorCode.java @@ -0,0 +1,34 @@ +package umc.cockple.demo.domain.file.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import umc.cockple.demo.global.response.code.BaseErrorCode; +import umc.cockple.demo.global.response.dto.ErrorReasonDTO; + +@Getter +@RequiredArgsConstructor +public enum GcsErrorCode implements BaseErrorCode { + + /** + * 1xx: 클라이언트가 수정해야 할 입력값 문제 + * 2xx: 서버에서 리소스를 찾을 수 없는 문제 + * 3xx: 권한/인증 문제 + * 4xx: 비즈니스 로직 위반 + */ + FILE_UPLOAD_GCS_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "FILE501", "파일 업로드 중 GCS 예외가 발생하였습니다. 서버 관리자에게 문의해주세요."), + FILE_UPLOAD_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "FILE502", "파일 업로드 중 IO 예외가 발생하였습니다. 서버 관리자에게 문의해주세요."), + FILE_STILL_EXIST(HttpStatus.INTERNAL_SERVER_ERROR,"FILE503" ,"파일이 삭제되지 않고 GCS에 남아있습니다. 서버 관리자에게 문의해주세요." ), + FILE_DELETE_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR,"FILE504" ,"파일 삭제에 실패하였습니다. 서버 관리자에게 문의해주세요." ), + FILE_BUCKET_DIRECTORY_NULL(HttpStatus.BAD_REQUEST, "FILE505", "버킷 디렉토리 값이 유효하지 않습니다."); + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.of(code, message, httpStatus); + } +} \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java b/src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java new file mode 100644 index 000000000..11e8672b5 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/exception/GcsException.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.file.exception; + +import umc.cockple.demo.global.exception.GeneralException; +import umc.cockple.demo.global.response.code.BaseErrorCode; + +public class GcsException extends GeneralException { + + public GcsException(BaseErrorCode code) { super(code); } +} diff --git a/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java b/src/main/java/umc/cockple/demo/domain/file/service/FileService.java similarity index 61% rename from src/main/java/umc/cockple/demo/domain/image/service/ImageService.java rename to src/main/java/umc/cockple/demo/domain/file/service/FileService.java index ff74504c5..1bd7121fc 100644 --- a/src/main/java/umc/cockple/demo/domain/image/service/ImageService.java +++ b/src/main/java/umc/cockple/demo/domain/file/service/FileService.java @@ -1,132 +1,110 @@ -package umc.cockple.demo.domain.image.service; - -import com.google.cloud.storage.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; -import org.springframework.web.multipart.MultipartFile; -import umc.cockple.demo.domain.image.dto.FileUploadDTO; -import umc.cockple.demo.domain.image.dto.ImageUploadDTO; -import umc.cockple.demo.domain.image.exception.S3ErrorCode; -import umc.cockple.demo.domain.image.exception.S3Exception; -import umc.cockple.demo.global.enums.DomainType; - -import java.io.IOException; -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Slf4j -public class ImageService { - - @Value("${gcs.bucket}") - private String bucket; - - private final Storage storage; - - public ImageUploadDTO.Response uploadImage(MultipartFile image, DomainType domainType) { - if (image == null || image.isEmpty()) { - return null; - } - - log.info("[이미지 업로드 시작]"); - - String originalFileName = image.getOriginalFilename(); - String key = getFileKey(image, domainType); - String imgUrl = uploadToGcs(image, key); - - log.info("[이미지 업로드 완료]"); - return ImageUploadDTO.Response.builder() - .imgUrl(imgUrl) - .imgKey(key) - .originalFileName(originalFileName) - .fileSize(image.getSize()) - .fileType(image.getContentType()) - .build(); - } - - public FileUploadDTO.Response uploadFile(MultipartFile file, DomainType domainType) { - if (file == null || file.isEmpty()) { - return null; - } - - log.info("[파일 업로드 시작]"); - - String originalFileName = file.getOriginalFilename(); - String key = getFileKey(file, domainType); - String fileUrl = uploadToGcs(file, key); - - log.info("[파일 업로드 완료]"); - return FileUploadDTO.Response.builder() - .fileKey(key) - .fileUrl(fileUrl) - .originalFileName(originalFileName) - .fileSize(file.getSize()) - .fileType(file.getContentType()) - .build(); - } - - public List uploadImages(List images, DomainType domainType) { - if (images == null || images.isEmpty()) { - return List.of(); - } - - return images.stream() - .map(img -> uploadImage(img, domainType)) - .collect(Collectors.toList()); - } - - public void delete(String imgKey) { - try { - storage.delete(BlobId.of(bucket, imgKey)); - log.info("[GCS 삭제 성공] {}", imgKey); - } catch (Exception e) { - log.error("[GCS 삭제 실패] {}", e.getMessage()); - throw new S3Exception(S3ErrorCode.IMAGE_DELETE_EXCEPTION); - } - } - - private String uploadToGcs(MultipartFile file, String key) { - try { - BlobInfo blobInfo = BlobInfo.newBuilder(bucket, key) - .setContentType(file.getContentType()) - .build(); - storage.create(blobInfo, file.getBytes()); - return String.format("https://storage.googleapis.com/%s/%s", bucket, key); - } catch (IOException e) { - log.error("[GCS 업로드 실패 - IO 예외] {}", e.getMessage()); - throw new S3Exception(S3ErrorCode.FILE_UPLOAD_IO_EXCEPTION); - } catch (StorageException e) { - log.error("[GCS 업로드 실패 - Storage 예외] {}", e.getMessage()); - throw new S3Exception(S3ErrorCode.FILE_UPLOAD_AMAZON_EXCEPTION); - } - } - - public String getFileKey(MultipartFile file, DomainType domainType) { - if (file == null || file.isEmpty()) { - return null; - } - - String originalFilename = file.getOriginalFilename(); - String extension = StringUtils.getFilenameExtension(originalFilename); - String uuid = UUID.randomUUID().toString(); - - return domainType.getDirectory() + "/" + uuid + "." + extension; - } - - public String getUrlFromKey(String key) { - return String.format("https://storage.googleapis.com/%s/%s", bucket, key); - } - - public Blob downloadFile(String fileKey) { - Blob blob = storage.get(BlobId.of(bucket, fileKey)); - if (blob == null) { - throw new S3Exception(S3ErrorCode.IMAGE_DELETE_EXCEPTION); - } - return blob; - } +package umc.cockple.demo.domain.file.service; + +import com.google.cloud.storage.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import umc.cockple.demo.domain.file.dto.FileUploadDTO; +import umc.cockple.demo.domain.file.exception.GcsErrorCode; +import umc.cockple.demo.domain.file.exception.GcsException; +import umc.cockple.demo.global.enums.DomainType; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FileService { + + @Value("${gcs.bucket}") + private String bucket; + + private final Storage storage; + + public FileUploadDTO.Response uploadFile(MultipartFile file, DomainType domainType) { + if (file == null || file.isEmpty()) { + return null; + } + + log.info("[파일 업로드 시작]"); + + String originalFileName = file.getOriginalFilename(); + String key = getFileKey(file, domainType); + String fileUrl = uploadToGcs(file, key); + + log.info("[파일 업로드 완료]"); + return FileUploadDTO.Response.builder() + .fileKey(key) + .fileUrl(fileUrl) + .originalFileName(originalFileName) + .fileSize(file.getSize()) + .fileType(file.getContentType()) + .build(); + } + + public List uploadFiles(List files, DomainType domainType) { + if (files == null || files.isEmpty()) { + return List.of(); + } + + return files.stream() + .map(file -> uploadFile(file, domainType)) + .collect(Collectors.toList()); + } + + public void delete(String fileKey) { + try { + storage.delete(BlobId.of(bucket, fileKey)); + log.info("[GCS 삭제 성공] {}", fileKey); + } catch (Exception e) { + log.error("[GCS 삭제 실패] {}", e.getMessage()); + throw new GcsException(GcsErrorCode.FILE_DELETE_EXCEPTION); + } + } + + private String uploadToGcs(MultipartFile file, String key) { + try { + BlobInfo blobInfo = BlobInfo.newBuilder(bucket, key) + .setContentType(file.getContentType()) + .build(); + storage.create(blobInfo, file.getBytes()); + return String.format("https://storage.googleapis.com/%s/%s", bucket, key); + } catch (IOException e) { + log.error("[GCS 업로드 실패 - IO 예외] {}", e.getMessage()); + throw new GcsException(GcsErrorCode.FILE_UPLOAD_IO_EXCEPTION); + } catch (StorageException e) { + log.error("[GCS 업로드 실패 - Storage 예외] {}", e.getMessage()); + throw new GcsException(GcsErrorCode.FILE_UPLOAD_GCS_EXCEPTION); + } + } + + public String getFileKey(MultipartFile file, DomainType domainType) { + if (file == null || file.isEmpty()) { + return null; + } + + String originalFilename = file.getOriginalFilename(); + String extension = StringUtils.getFilenameExtension(originalFilename); + String uuid = UUID.randomUUID().toString(); + + return domainType.getDirectory() + "/" + uuid + "." + extension; + } + + public String getUrlFromKey(String key) { + return String.format("https://storage.googleapis.com/%s/%s", bucket, key); + } + + public Blob downloadFile(String fileKey) { + Blob blob = storage.get(BlobId.of(bucket, fileKey)); + if (blob == null) { + throw new GcsException(GcsErrorCode.FILE_DELETE_EXCEPTION); + } + return blob; + } } \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java b/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java deleted file mode 100644 index aef8c61c7..000000000 --- a/src/main/java/umc/cockple/demo/domain/image/controller/ImgController.java +++ /dev/null @@ -1,52 +0,0 @@ -package umc.cockple.demo.domain.image.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; -import umc.cockple.demo.domain.image.dto.FileUploadDTO; -import umc.cockple.demo.domain.image.dto.ImageUploadDTO; -import umc.cockple.demo.domain.image.service.ImageService; -import umc.cockple.demo.global.enums.DomainType; -import umc.cockple.demo.global.response.BaseResponse; -import umc.cockple.demo.global.response.code.status.CommonSuccessCode; - -import java.util.List; - -@RestController -@RequestMapping("/api") -@RequiredArgsConstructor -@Validated -@Tag(name = "Image", description = "이미지 API") -public class ImgController { - - private final ImageService imageService; - - @PostMapping(value = "/s3/upload/img", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "이미지 업로드", description = "GCS에 이미지를 업로드하고 이미지 URL과 imgKey를 반환합니다.") - public BaseResponse imgUpload(@RequestPart("image") MultipartFile image, - @RequestParam("domainType") DomainType domainType) { - - return BaseResponse.success(CommonSuccessCode.ACCEPTED, imageService.uploadImage(image, domainType)); - } - - - @PostMapping(value = "/s3/upload/imgs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "이미지 여러장 업로드", description = "GCS에 이미지 여러장을 업로드하고 이미지 URL과 imgKey를 반환합니다.") - public BaseResponse> imgUpload(@RequestPart("image") List images, - @RequestParam("domainType") DomainType domainType) { - - return BaseResponse.success(CommonSuccessCode.ACCEPTED, imageService.uploadImages(images, domainType)); - } - - @PostMapping(value = "/s3/upload/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "파일 업로드", description = "GCS에 파일을 업로드하고 파일정보를 반환합니다.") - public BaseResponse fileUpload(@RequestPart("file") MultipartFile file, - @RequestParam("domainType") DomainType domainType) { - - return BaseResponse.success(CommonSuccessCode.ACCEPTED, imageService.uploadFile(file, domainType)); - } -} diff --git a/src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java b/src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java deleted file mode 100644 index 1d9844338..000000000 --- a/src/main/java/umc/cockple/demo/domain/image/dto/ImageUploadDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package umc.cockple.demo.domain.image.dto; - -import lombok.Builder; - -public class ImageUploadDTO{ - @Builder - public record Response( - String imgUrl, - String imgKey, - String originalFileName, - Long fileSize, - String fileType - ) {} -} diff --git a/src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java b/src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java deleted file mode 100644 index 751a48a5f..000000000 --- a/src/main/java/umc/cockple/demo/domain/image/exception/S3ErrorCode.java +++ /dev/null @@ -1,36 +0,0 @@ -package umc.cockple.demo.domain.image.exception; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import umc.cockple.demo.global.response.code.BaseErrorCode; -import umc.cockple.demo.global.response.dto.ErrorReasonDTO; - -@Getter -@RequiredArgsConstructor -public enum S3ErrorCode implements BaseErrorCode { - - /** - * 1xx: 클라이언트가 수정해야 할 입력값 문제 - * 2xx: 서버에서 리소스를 찾을 수 없는 문제 - * 3xx: 권한/인증 문제 - * 4xx: 비즈니스 로직 위반 - */ - IMAGE_UPLOAD_AMAZON_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG501", "이미지 업로드 중, AWS 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"), - IMAGE_UPLOAD_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG502", "이미지 업로드 중, IO 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"), - IMAGE_STILL_EXIST(HttpStatus.INTERNAL_SERVER_ERROR,"IMG503" ,"이미지가 삭제되지 않고 S3에 남아있습니다. 서버 관리자에게 문의해주세요" ), - IMAGE_DELETE_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR,"IMG504" ,"이미지 삭제에 실패하였습니다. 서버관리자에게 문의해주세요" ), - IMAGE_BUCKET_DIRECTORY_NULL(HttpStatus.BAD_REQUEST, "IMG505", "버킷 디렉토리 값이 유효하지 않습니다."), - FILE_UPLOAD_AMAZON_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG506", "파일 업로드 중, AWS 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"), - FILE_UPLOAD_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "IMG507", "파일 업로드 중, IO 예외가 발생하였습니다. 서버 관리자에게 문의해주세요"), - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.of(code, message, httpStatus); - } -} \ No newline at end of file diff --git a/src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java b/src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java deleted file mode 100644 index aefb75ce9..000000000 --- a/src/main/java/umc/cockple/demo/domain/image/exception/S3Exception.java +++ /dev/null @@ -1,9 +0,0 @@ -package umc.cockple.demo.domain.image.exception; - -import umc.cockple.demo.global.exception.GeneralException; -import umc.cockple.demo.global.response.code.BaseErrorCode; - -public class S3Exception extends GeneralException { - - public S3Exception(BaseErrorCode code) { super(code); } -} diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index c1d0a0c7a..8dfcc3661 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -15,7 +15,7 @@ import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.domain.member.enums.MemberStatus; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import java.time.LocalDate; import java.time.LocalTime; @@ -38,7 +38,7 @@ public class MemberCommandService { private final ChatRoomMemberRepository chatRoomMemberRepository; private final KakaoOauthService kakaoOauthService; - private final ImageService imageService; + private final FileService fileService; // ==================== 회원 관련 =================== @@ -138,7 +138,7 @@ public void updateProfile(UpdateProfileRequestDTO requestDto, Long memberId) { // 프로필 사진이 변경되었을 경우에만 이미지 url 변경 및 S3 사진 변경 if (!profile.getImgKey().equals(imgKey)) { - imageService.delete(profile.getImgKey()); + fileService.delete(profile.getImgKey()); profile.updateProfile(imgKey); } diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java index b140f1a56..a8c812574 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberQueryService.java @@ -7,7 +7,7 @@ import umc.cockple.demo.domain.chat.dto.MemberConnectionInfo; import umc.cockple.demo.domain.contest.domain.Contest; import umc.cockple.demo.domain.contest.enums.MedalType; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.converter.MemberConverter; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; @@ -34,7 +34,7 @@ public class MemberQueryService { private final MemberRepository memberRepository; - private final ImageService imageService; + private final FileService fileService; /* * 프로필 관련 조회 메서드 @@ -69,7 +69,7 @@ public GetProfileResponseDTO getProfile(Long memberId) { // 프로필 사진 null-safety String imgUrl = null; if (member.getProfileImg() != null) { - imgUrl = imageService.getUrlFromKey(member.getProfileImg().getImgKey()); + imgUrl = fileService.getUrlFromKey(member.getProfileImg().getImgKey()); } // 각 메달 개수 카운트 diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java index ec03d694b..22eaa5afd 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java @@ -4,12 +4,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.notification.controller.NotificationController; import umc.cockple.demo.domain.notification.converter.NotificationConverter; import umc.cockple.demo.domain.notification.domain.Notification; import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO; @@ -17,7 +16,6 @@ import umc.cockple.demo.domain.notification.repository.NotificationRepository; import java.util.List; -import java.util.stream.Collectors; @Service @Transactional(readOnly = true) @@ -27,7 +25,7 @@ public class NotificationQueryService { private final NotificationRepository notificationRepository; private final MemberRepository memberRepository; - private final ImageService imageService; + private final FileService fileService; public List getAllNotifications(Long memberId) { @@ -43,7 +41,7 @@ public List getAllNotifications(Long memberId) { // dto 매핑 및 반환 return notifications.stream() .map(notification -> { - String url = imageService.getUrlFromKey(notification.getImageKey()); + String url = fileService.getUrlFromKey(notification.getImageKey()); return NotificationConverter.toAllNotificationResponseDTO(notification, url); }) .toList(); diff --git a/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java b/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java index 70448c423..4f87a3a86 100644 --- a/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java +++ b/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberParty; import umc.cockple.demo.domain.member.domain.ProfileImg; @@ -25,7 +25,7 @@ @RequiredArgsConstructor public class PartyConverter { - private final ImageService imageService; + private final FileService fileService; public PartySimpleDTO.Response toPartySimpleDTO(MemberParty memberParty, String imgUrl) { Party party = memberParty.getParty(); @@ -220,7 +220,7 @@ private int getRolePriority(String role) { private String getProfileUrl(ProfileImg profileImg) { if (profileImg != null && profileImg.getImgKey() != null && !profileImg.getImgKey().isBlank()) { - return imageService.getUrlFromKey(profileImg.getImgKey()); + return fileService.getUrlFromKey(profileImg.getImgKey()); } return null; } diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java index 82894ac5f..eaf47b21c 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java @@ -9,7 +9,7 @@ import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.*; import umc.cockple.demo.domain.member.exception.MemberErrorCode; import umc.cockple.demo.domain.member.exception.MemberException; @@ -53,7 +53,7 @@ public class PartyQueryServiceImpl implements PartyQueryService{ private final ExerciseRepository exerciseRepository; private final MemberExerciseRepository memberExerciseRepository; private final PartyBookmarkRepository partyBookmarkRepository; - private final ImageService imageService; + private final FileService fileService; @Override public Slice getSimpleMyParties(Long memberId, Pageable pageable) { @@ -371,14 +371,14 @@ private boolean hasPendingJoinRequest(Party party, Member member, Optional chatFileService.issueDownloadToken(999L, 1L)); + + assertThat(exception.getCode()).isEqualTo(ChatErrorCode.FILE_NOT_FOUND); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 경우 권한 부족으로 CHAT_ROOM_ACCESS_DENIED 예외가 발생한다") + void throwExceptionWhenAccessDenied() { + given(chatFileRepository.findById(100L)).willReturn(Optional.of(chatFile)); + given(chatRoomMemberRepository.existsByChatRoomIdAndMemberId(10L, 2L)).willReturn(false); + + ChatException exception = assertThrows( + ChatException.class, () -> chatFileService.issueDownloadToken(100L, 2L) + ); + assertThat(exception.getCode()).isEqualTo(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED); + } + } + + // ========== downloadFile ========== + @Nested + @DisplayName("downloadFile - 실제 파일 다운로드 테스트") + class DownloadFile { + + @Test + @DisplayName("유효한 토큰 사용 시 Blob 데이터를 기반으로 정상적으로 ResponseEntity를 반환한다") + void success() { + DownloadToken token = DownloadToken.create(100L, 1L, 180); + ReflectionTestUtils.setField(token, "expiresAt", LocalDateTime.now().plusMinutes(5)); + given(downloadTokenRepository.findByToken("valid-token")).willReturn(Optional.of(token)); + given(chatFileRepository.findById(100L)).willReturn(Optional.of(chatFile)); + Blob mockBlob = mock(Blob.class); + given(mockBlob.getSize()).willReturn(1024L); + given(mockBlob.getContentType()).willReturn("image/webp"); + given(mockBlob.getContent()).willReturn(new byte[1024]); + given(fileService.downloadFile("test/key.webp")).willReturn(mockBlob); + + ResponseEntity response = chatFileService.downloadFile(100L, "valid-token"); + + assertThat(response.getStatusCodeValue()).isEqualTo(200); + assertThat(response.getHeaders().getContentLength()).isEqualTo(1024L); + assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/webp"); + verify(downloadTokenRepository).delete(token); + } + + @Test + @DisplayName("DB에 존재하지 않거나 만료된 토큰의 경우 INVALID_DOWNLOAD_TOKEN 예외가 발생한다") + void throwExceptionWhenInvalidToken() { + DownloadToken token = DownloadToken.create(100L, 1L, 0); + ReflectionTestUtils.setField(token, "expiresAt", LocalDateTime.now().minusMinutes(5)); // 만료된 토큰 + given(downloadTokenRepository.findByToken("expired-token")).willReturn(Optional.of(token)); + + ChatException exception = assertThrows(ChatException.class, () -> + chatFileService.downloadFile(100L, "expired-token") + ); + assertThat(exception.getCode()).isEqualTo(ChatErrorCode.INVALID_DOWNLOAD_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java index 7bfbf7fae..fe301689b 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatProcessorTest.java @@ -10,11 +10,11 @@ import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.chat.converter.ChatConverter; import umc.cockple.demo.domain.chat.domain.ChatMessage; -import umc.cockple.demo.domain.chat.domain.ChatMessageImg; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.dto.ChatCommonDTO; import umc.cockple.demo.domain.chat.enums.MessageType; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.ProfileImg; import umc.cockple.demo.global.enums.Gender; @@ -35,7 +35,7 @@ class ChatProcessorTest { @Mock - private ImageService imageService; + private FileService fileService; private ChatConverter chatConverter; private ChatProcessor chatProcessor; @@ -46,7 +46,7 @@ class ChatProcessorTest { @BeforeEach void setUp() { chatConverter = new ChatConverter(); - chatProcessor = new ChatProcessor(imageService, chatConverter); + chatProcessor = new ChatProcessor(fileService, chatConverter); sender = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); ReflectionTestUtils.setField(sender, "id", 10L); @@ -69,7 +69,7 @@ void returnsNull_whenProfileImgIsNull() { String result = chatProcessor.generateProfileImageUrl(null); assertThat(result).isNull(); - verify(imageService, never()).getUrlFromKey(null); + verify(fileService, never()).getUrlFromKey(null); } @Test @@ -82,7 +82,7 @@ void returnsNull_whenImgKeyIsNull() { String result = chatProcessor.generateProfileImageUrl(profileImg); assertThat(result).isNull(); - verify(imageService, never()).getUrlFromKey(null); + verify(fileService, never()).getUrlFromKey(null); } @Test @@ -95,7 +95,7 @@ void returnsNull_whenImgKeyIsBlank() { String result = chatProcessor.generateProfileImageUrl(profileImg); assertThat(result).isNull(); - verify(imageService, never()).getUrlFromKey(" "); + verify(fileService, never()).getUrlFromKey(" "); } @Test @@ -105,82 +105,82 @@ void returnsUrl_whenImgKeyIsValid() { .imgKey("profile/key123.jpg") .build(); - given(imageService.getUrlFromKey("profile/key123.jpg")) + given(fileService.getUrlFromKey("profile/key123.jpg")) .willReturn("https://cdn.example.com/profile/key123.jpg"); String result = chatProcessor.generateProfileImageUrl(profileImg); assertThat(result).isEqualTo("https://cdn.example.com/profile/key123.jpg"); - verify(imageService).getUrlFromKey("profile/key123.jpg"); + verify(fileService).getUrlFromKey("profile/key123.jpg"); } } - // ========== generateImageUrl ========== + // ========== generateFileUrl ========== @Nested - @DisplayName("generateImageUrl - 채팅 이미지 URL 생성") - class GenerateImageUrl { + @DisplayName("generateFileUrl - 채팅 파일 URL 생성") + class GenerateFileUrl { @Test - @DisplayName("img가 null이면 null을 반환한다") - void returnsNull_whenImgIsNull() { - String result = chatProcessor.generateImageUrl(null); + @DisplayName("file이 null이면 null을 반환한다") + void returnsNull_whenFileIsNull() { + String result = chatProcessor.generateFileUrl(null); assertThat(result).isNull(); } @Test - @DisplayName("imgKey가 null이면 null을 반환하고 imageService를 호출하지 않는다") - void returnsNull_whenImgKeyIsNull() { - ChatMessageImg img = ChatMessageImg.builder() - .imgKey(null) - .imgOrder(1) + @DisplayName("fileKey가 null이면 null을 반환하고 fileService를 호출하지 않는다") + void returnsNull_whenFileKeyIsNull() { + ChatMessageFile img = ChatMessageFile.builder() + .fileKey(null) + .fileOrder(1) .originalFileName("photo.jpg") .fileSize(1024L) .fileType("image/jpeg") .build(); - String result = chatProcessor.generateImageUrl(img); + String result = chatProcessor.generateFileUrl(img); assertThat(result).isNull(); - verify(imageService, never()).getUrlFromKey(null); + verify(fileService, never()).getUrlFromKey(null); } @Test - @DisplayName("imgKey가 공백 문자열이면 null을 반환하고 imageService를 호출하지 않는다") - void returnsNull_whenImgKeyIsBlank() { - ChatMessageImg img = ChatMessageImg.builder() - .imgKey(" ") - .imgOrder(1) + @DisplayName("fileKey가 공백 문자열이면 null을 반환하고 fileService를 호출하지 않는다") + void returnsNull_whenFileKeyIsBlank() { + ChatMessageFile img = ChatMessageFile.builder() + .fileKey(" ") + .fileOrder(1) .originalFileName("photo.jpg") .fileSize(1024L) .fileType("image/jpeg") .build(); - String result = chatProcessor.generateImageUrl(img); + String result = chatProcessor.generateFileUrl(img); assertThat(result).isNull(); - verify(imageService, never()).getUrlFromKey(" "); + verify(fileService, never()).getUrlFromKey(" "); } @Test - @DisplayName("유효한 imgKey가 있으면 imageService로 URL을 생성해서 반환한다") - void returnsUrl_whenImgKeyIsValid() { - ChatMessageImg img = ChatMessageImg.builder() - .imgKey("chat/img456.jpg") - .imgOrder(1) + @DisplayName("유효한 fileKey가 있으면 fileService로 URL을 생성해서 반환한다") + void returnsUrl_whenFileKeyIsValid() { + ChatMessageFile img = ChatMessageFile.builder() + .fileKey("chat/img456.jpg") + .fileOrder(1) .originalFileName("photo.jpg") .fileSize(2048L) .fileType("image/jpeg") .build(); - given(imageService.getUrlFromKey("chat/img456.jpg")) + given(fileService.getUrlFromKey("chat/img456.jpg")) .willReturn("https://cdn.example.com/chat/img456.jpg"); - String result = chatProcessor.generateImageUrl(img); + String result = chatProcessor.generateFileUrl(img); assertThat(result).isEqualTo("https://cdn.example.com/chat/img456.jpg"); - verify(imageService).getUrlFromKey("chat/img456.jpg"); + verify(fileService).getUrlFromKey("chat/img456.jpg"); } } @@ -277,7 +277,7 @@ void senderProfileImageUrl_isNull_whenNoProfileImg() { List result = chatProcessor.processMessages(sender.getId(), List.of(message)); assertThat(result.get(0).senderProfileImageUrl()).isNull(); - verify(imageService, never()).getUrlFromKey(null); + verify(fileService, never()).getUrlFromKey(null); } @Test @@ -305,44 +305,44 @@ void multipleMessages_preserveInputOrder() { } @Test - @DisplayName("이미지가 포함된 메시지는 imgOrder 오름차순으로 정렬된다") - void messageImages_areSortedByImgOrder() { + @DisplayName("이미지가 포함된 메시지는 fileOrder 오름차순으로 정렬된다") + void messageImages_areSortedByFileOrder() { ChatMessage message = ChatFixture.createTextMessage(chatRoom, sender, "이미지 메시지"); ReflectionTestUtils.setField(message, "id", 1L); - // imgOrder 역순으로 삽입: 3 → 1 → 2 - ChatMessageImg img3 = ChatMessageImg.builder() - .imgKey("img/third.jpg") - .imgOrder(3) + // fileOrder 역순으로 삽입: 3 → 1 → 2 + ChatMessageFile img3 = ChatMessageFile.builder() + .fileKey("img/third.jpg") + .fileOrder(3) .originalFileName("third.jpg") .fileSize(100L) .fileType("image/jpeg") .build(); - ChatMessageImg img1 = ChatMessageImg.builder() - .imgKey("img/first.jpg") - .imgOrder(1) + ChatMessageFile img1 = ChatMessageFile.builder() + .fileKey("img/first.jpg") + .fileOrder(1) .originalFileName("first.jpg") .fileSize(100L) .fileType("image/jpeg") .build(); - ChatMessageImg img2 = ChatMessageImg.builder() - .imgKey("img/second.jpg") - .imgOrder(2) + ChatMessageFile img2 = ChatMessageFile.builder() + .fileKey("img/second.jpg") + .fileOrder(2) .originalFileName("second.jpg") .fileSize(100L) .fileType("image/jpeg") .build(); - // ChatMessage의 chatMessageImgs에 역순으로 세팅 - ReflectionTestUtils.setField(message, "chatMessageImgs", List.of(img3, img1, img2)); + // ChatMessage의 chatMessageFiles에 역순으로 세팅 + ReflectionTestUtils.setField(message, "chatMessageFiles", List.of(img3, img1, img2)); - given(imageService.getUrlFromKey("img/first.jpg")).willReturn("https://cdn.example.com/first.jpg"); - given(imageService.getUrlFromKey("img/second.jpg")).willReturn("https://cdn.example.com/second.jpg"); - given(imageService.getUrlFromKey("img/third.jpg")).willReturn("https://cdn.example.com/third.jpg"); + given(fileService.getUrlFromKey("img/first.jpg")).willReturn("https://cdn.example.com/first.jpg"); + given(fileService.getUrlFromKey("img/second.jpg")).willReturn("https://cdn.example.com/second.jpg"); + given(fileService.getUrlFromKey("img/third.jpg")).willReturn("https://cdn.example.com/third.jpg"); List result = chatProcessor.processMessages(sender.getId(), List.of(message)); - List images = result.get(0).images(); + List images = result.get(0).images(); assertThat(images).hasSize(3); assertThat(images.get(0).imgOrder()).isEqualTo(1); assertThat(images.get(1).imgOrder()).isEqualTo(2); diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java index f5455789e..47001068c 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatQueryServiceTest.java @@ -10,11 +10,13 @@ import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.chat.converter.ChatConverter; import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.dto.ChatMessageDTO; import umc.cockple.demo.domain.chat.dto.ChatRoomDetailDTO; import umc.cockple.demo.domain.chat.enums.ChatRoomType; +import umc.cockple.demo.domain.chat.enums.MessageType; import umc.cockple.demo.domain.chat.exception.ChatErrorCode; import umc.cockple.demo.domain.chat.exception.ChatException; import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; @@ -22,7 +24,7 @@ import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.party.domain.Party; @@ -54,7 +56,7 @@ class ChatQueryServiceTest { @Mock private MemberPartyRepository memberPartyRepository; @Mock private MessageReadStatusRepository messageReadStatusRepository; @Mock private ChatRoomListCacheService chatRoomListCacheService; - @Mock private ImageService imageService; + @Mock private FileService fileService; private ChatConverter chatConverter; private ChatProcessor chatProcessor; @@ -63,7 +65,7 @@ class ChatQueryServiceTest { @BeforeEach void setUp() { chatConverter = new ChatConverter(); - chatProcessor = new ChatProcessor(imageService, chatConverter); + chatProcessor = new ChatProcessor(fileService, chatConverter); chatQueryService = new ChatQueryServiceImpl( chatRoomRepository, chatRoomMemberRepository, @@ -72,7 +74,7 @@ void setUp() { memberPartyRepository, messageReadStatusRepository, chatConverter, - imageService, + fileService, chatProcessor, chatRoomListCacheService ); @@ -105,7 +107,7 @@ void partyChatRoom_success() { given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of()); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of()); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(participants); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1); @@ -154,7 +156,7 @@ void directChatRoom_success() { given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); given(chatRoomMemberRepository.findCounterPartWithMember(roomId, memberId)).willReturn(Optional.of(counterPartMembership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of()); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of()); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(participants); // when @@ -197,7 +199,7 @@ void directChatRoom_counterPartWithdrawn() { given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); given(chatRoomMemberRepository.findCounterPartWithMember(roomId, memberId)).willReturn(Optional.of(withdrawnMembership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of()); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of()); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(participants); // when @@ -236,7 +238,7 @@ void messages_areReversedToChronologicalOrder() { given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(msg3, msg2, msg1)); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(msg3, msg2, msg1)); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1); @@ -275,7 +277,7 @@ void message_isMyMessage_true_whenSenderIsMe() { given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(myMessage)); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(myMessage)); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1); @@ -286,6 +288,7 @@ void message_isMyMessage_true_whenSenderIsMe() { assertThat(result.messages().get(0).isMyMessage()).isTrue(); assertThat(result.messages().get(0).content()).isEqualTo("내 메시지"); assertThat(result.messages().get(0).senderName()).isEqualTo("홍길동"); + assertThat(result.messages().get(0).images()).isEmpty(); } @Test @@ -319,7 +322,7 @@ void message_isMyMessage_false_whenSenderIsOther() { given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(otherMessage)); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(otherMessage)); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership, otherMembership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2); @@ -362,7 +365,7 @@ void message_isSenderWithdrawn_true() { given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom)); given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); - given(chatMessageRepository.findRecentMessagesWithImages(eq(roomId), any())).willReturn(List.of(withdrawnMessage)); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(withdrawnMessage)); given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership, withdrawnMembership)); given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(2); @@ -373,6 +376,57 @@ void message_isSenderWithdrawn_true() { assertThat(result.messages().get(0).isSenderWithdrawn()).isTrue(); } + @Test + @DisplayName("이미지 메시지 조회 시 images 필드에 파일 정보가 포함된다") + void imageMessage_containsFileInfo() { + // given + Long roomId = 1L; + Long memberId = 10L; + + Member me = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(me, "id", memberId); + + Party party = PartyFixture.createParty("모임", memberId, PartyFixture.createPartyAddr("서울", "강남구")); + ReflectionTestUtils.setField(party, "id", 100L); + + ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + + ChatRoomMember myMembership = ChatFixture.createJoinedMember(chatRoom, me); + ReflectionTestUtils.setField(myMembership, "id", 1L); + + ChatMessage imageMessage = ChatFixture.createImageMessage(chatRoom, me, List.of()); + ReflectionTestUtils.setField(imageMessage, "id", 1L); + + ChatMessageFile file1 = ChatFixture.createChatMessageFile(imageMessage, "chat/img1.png", 1, "photo1.png"); + ReflectionTestUtils.setField(file1, "id", 100L); + ChatMessageFile file2 = ChatFixture.createChatMessageFile(imageMessage, "chat/img2.png", 2, "photo2.png"); + ReflectionTestUtils.setField(file2, "id", 101L); + imageMessage.getChatMessageFiles().addAll(List.of(file1, file2)); + + given(chatRoomRepository.findChatRoomWithPartyById(roomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndMemberId(roomId, memberId)).willReturn(Optional.of(myMembership)); + given(chatMessageRepository.findRecentMessagesWithFiles(eq(roomId), any())).willReturn(List.of(imageMessage)); + given(chatRoomMemberRepository.findChatRoomMembersWithMemberById(roomId)).willReturn(List.of(myMembership)); + given(chatRoomMemberRepository.countByChatRoomId(roomId)).willReturn(1); + given(fileService.getUrlFromKey("chat/img1.png")).willReturn("https://storage.example.com/chat/img1.png"); + given(fileService.getUrlFromKey("chat/img2.png")).willReturn("https://storage.example.com/chat/img2.png"); + + // when + ChatRoomDetailDTO.Response result = chatQueryService.getChatRoomDetail(roomId, memberId); + + // then + ChatRoomDetailDTO.MessageInfo message = result.messages().get(0); + assertThat(message.messageType()).isEqualTo(MessageType.TEXT); + assertThat(message.images()).hasSize(2); + assertThat(message.images().get(0).imageUrl()).isEqualTo("https://storage.example.com/chat/img1.png"); + assertThat(message.images().get(0).imgOrder()).isEqualTo(1); + assertThat(message.images().get(0).originalFileName()).isEqualTo("photo1.png"); + assertThat(message.images().get(0).isEmoji()).isFalse(); + assertThat(message.images().get(1).imageUrl()).isEqualTo("https://storage.example.com/chat/img2.png"); + assertThat(message.images().get(1).imgOrder()).isEqualTo(2); + } + @Test @DisplayName("존재하지 않는 채팅방 조회 시 ChatException(CHAT_ROOM_NOT_FOUND)을 던진다") void fail_chatRoomNotFound() { @@ -563,6 +617,7 @@ void myMessage_isMyMessageTrue() { // then assertThat(result.messages().get(0).isMyMessage()).isTrue(); + assertThat(result.messages().get(0).images()).isEmpty(); } @Test @@ -636,6 +691,53 @@ void withdrawnSenderMessage_isSenderWithdrawnTrue() { assertThat(result.messages().get(0).isSenderWithdrawn()).isTrue(); } + @Test + @DisplayName("이미지 메시지 조회 시 images 필드에 파일 정보가 포함된다") + void imageMessage_containsFileInfo() { + // given + Long roomId = 1L; + Long memberId = 10L; + Long cursor = 100L; + + Member me = MemberFixture.createMemberWithName("홍길동", "길동", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(me, "id", memberId); + + Party party = PartyFixture.createParty("모임", memberId, PartyFixture.createPartyAddr("서울", "강남구")); + ReflectionTestUtils.setField(party, "id", 100L); + + ChatRoom chatRoom = ChatFixture.createPartyChatRoom(party); + ReflectionTestUtils.setField(chatRoom, "id", roomId); + + ChatMessage imageMessage = ChatFixture.createImageMessage(chatRoom, me, List.of()); + ReflectionTestUtils.setField(imageMessage, "id", 1L); + + ChatMessageFile file1 = ChatFixture.createChatMessageFile(imageMessage, "chat/img1.png", 1, "photo1.png"); + ReflectionTestUtils.setField(file1, "id", 100L); + ChatMessageFile file2 = ChatFixture.createChatMessageFile(imageMessage, "chat/img2.png", 2, "photo2.png"); + ReflectionTestUtils.setField(file2, "id", 101L); + imageMessage.getChatMessageFiles().addAll(List.of(file1, file2)); + + given(chatRoomMemberRepository.existsByChatRoomIdAndMemberId(roomId, memberId)).willReturn(true); + given(chatMessageRepository.findByRoomIdAndIdLessThanOrderByCreatedAtDesc(eq(roomId), eq(cursor), any())) + .willReturn(List.of(imageMessage)); + given(fileService.getUrlFromKey("chat/img1.png")).willReturn("https://storage.example.com/chat/img1.png"); + given(fileService.getUrlFromKey("chat/img2.png")).willReturn("https://storage.example.com/chat/img2.png"); + + // when + ChatMessageDTO.Response result = chatQueryService.getChatMessages(roomId, memberId, cursor, 10); + + // then + ChatMessageDTO.MessageInfo message = result.messages().get(0); + assertThat(message.messageType()).isEqualTo(MessageType.TEXT); + assertThat(message.images()).hasSize(2); + assertThat(message.images().get(0).imageUrl()).isEqualTo("https://storage.example.com/chat/img1.png"); + assertThat(message.images().get(0).imgOrder()).isEqualTo(1); + assertThat(message.images().get(0).originalFileName()).isEqualTo("photo1.png"); + assertThat(message.images().get(0).isEmoji()).isFalse(); + assertThat(message.images().get(1).imageUrl()).isEqualTo("https://storage.example.com/chat/img2.png"); + assertThat(message.images().get(1).imgOrder()).isEqualTo(2); + } + @Test @DisplayName("채팅방 멤버가 아닌 사용자가 메시지를 조회하면 ChatException(CHAT_ROOM_ACCESS_DENIED)을 던진다") void fail_notChatRoomMember() { diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java index 5a98b3a02..af8355184 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java @@ -17,7 +17,7 @@ import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseLifecycleService; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; @@ -49,7 +49,7 @@ class ExerciseLifecycleServiceTest { @Mock private PartyRepository partyRepository; @Mock private MemberPartyRepository memberPartyRepository; @Mock private MemberExerciseRepository memberExerciseRepository; - @Mock private ImageService imageService; + @Mock private FileService fileService; private ExerciseLifecycleService exerciseLifecycleService; @@ -59,7 +59,7 @@ class ExerciseLifecycleServiceTest { @BeforeEach void setUp() { ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository); - ExerciseConverter exerciseConverter = new ExerciseConverter(imageService); + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); exerciseLifecycleService = new ExerciseLifecycleService( exerciseRepository, partyRepository, exerciseValidator, exerciseConverter); diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java index d3af78261..9050caf03 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java @@ -17,7 +17,7 @@ import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseParticipationService; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberExercise; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; @@ -49,7 +49,7 @@ class ExerciseParticipationServiceTest { @Mock private MemberPartyRepository memberPartyRepository; @Mock private MemberExerciseRepository memberExerciseRepository; @Mock private GuestRepository guestRepository; - @Mock private ImageService imageService; + @Mock private FileService fileService; private ExerciseParticipationService exerciseParticipationService; @@ -60,7 +60,7 @@ class ExerciseParticipationServiceTest { @BeforeEach void setUp() { ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository); - ExerciseConverter exerciseConverter = new ExerciseConverter(imageService); + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); exerciseParticipationService = new ExerciseParticipationService( exerciseRepository, memberRepository, memberPartyRepository, memberExerciseRepository, guestRepository, exerciseValidator, exerciseConverter); diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index da61c9fa6..59c0863e9 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -19,7 +19,7 @@ import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberExercise; import umc.cockple.demo.domain.member.domain.MemberParty; @@ -62,7 +62,7 @@ class ExerciseQueryServiceTest { @Mock private GuestRepository guestRepository; @Mock private PartyRepository partyRepository; @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; - @Mock private ImageService imageService; + @Mock private FileService fileService; private ExerciseConverter exerciseConverter; @@ -72,7 +72,7 @@ class ExerciseQueryServiceTest { @BeforeEach void setUp() { - exerciseConverter = new ExerciseConverter(imageService); + exerciseConverter = new ExerciseConverter(fileService); ReflectionTestUtils.setField(exerciseQueryService, "exerciseConverter", exerciseConverter); manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); diff --git a/src/test/java/umc/cockple/demo/domain/file/integration/FileIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/file/integration/FileIntegrationTest.java new file mode 100644 index 000000000..23f4c00af --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/integration/FileIntegrationTest.java @@ -0,0 +1,157 @@ +package umc.cockple.demo.domain.file.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.file.dto.FileUploadDTO; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.DomainType; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; + +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("File 통합 테스트") +class FileIntegrationTest extends IntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MemberRepository memberRepository; + + @MockitoBean + private FileService fileService; + + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createMember("홍길동", Gender.MALE, Level.A, 1001L)); + } + + @AfterEach + void tearDown() { + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + @Nested + @DisplayName("POST /api/gcs/upload/file - 단일 파일 업로드") + class UploadFile { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 정상적인 File과 DomainType이 주어지면 업로드 성공 응답을 반환한다") + void success_uploadSingleFile() throws Exception { + // 가상 파일 생성 + MockMultipartFile mockFile = new MockMultipartFile( + "file", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test image content".getBytes() + ); + + // 가상 응답 객체 생성 + FileUploadDTO.Response mockResponse = FileUploadDTO.Response.builder() + .fileKey("chat/test-key.jpg") + .fileUrl("https://storage.googleapis.com/test-bucket/chat/test-key.jpg") + .originalFileName("test.jpg") + .fileSize(18L) + .fileType(MediaType.IMAGE_JPEG_VALUE) + .build(); + + given(fileService.uploadFile(any(), eq(DomainType.CHAT))).willReturn(mockResponse); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(multipart("/api/gcs/upload/file") + .file(mockFile) + .param("domainType", "CHAT") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fileKey").value("chat/test-key.jpg")) + .andExpect(jsonPath("$.data.fileUrl").value("https://storage.googleapis.com/test-bucket/chat/test-key.jpg")) + .andExpect(jsonPath("$.data.originalFileName").value("test.jpg")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("400 - 파일 파라미터가 누락되면 실패 응답을 반환한다") + void fail_missingFileParam() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(multipart("/api/gcs/upload/file") + .param("domainType", "CHAT") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isBadRequest()); + } + } + } + + @Nested + @DisplayName("POST /api/gcs/upload/files - 다중 파일 업로드") + class UploadFiles { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("202 - 정상적인 파일 목록과 DomainType이 주어지면 업로드 성공 리스트를 반환한다") + void success_uploadMultipleFiles() throws Exception { + MockMultipartFile mockFile1 = new MockMultipartFile("file", "test1.jpg", MediaType.IMAGE_JPEG_VALUE, "content1".getBytes()); + MockMultipartFile mockFile2 = new MockMultipartFile("file", "test2.png", MediaType.IMAGE_PNG_VALUE, "content2".getBytes()); + + FileUploadDTO.Response mockResponse1 = FileUploadDTO.Response.builder() + .fileKey("party/key1.jpg") + .fileUrl("https://url.com/party/key1.jpg") + .originalFileName("test1.jpg") + .fileSize(8L) + .fileType(MediaType.IMAGE_JPEG_VALUE) + .build(); + + FileUploadDTO.Response mockResponse2 = FileUploadDTO.Response.builder() + .fileKey("party/key2.png") + .fileUrl("https://url.com/party/key2.png") + .originalFileName("test2.png") + .fileSize(8L) + .fileType(MediaType.IMAGE_PNG_VALUE) + .build(); + + given(fileService.uploadFiles(any(), eq(DomainType.PARTY))).willReturn(List.of(mockResponse1, mockResponse2)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(multipart("/api/gcs/upload/files") + .file(mockFile1) + .file(mockFile2) + .param("domainType", "PARTY") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].fileKey").value("party/key1.jpg")) + .andExpect(jsonPath("$.data[1].fileKey").value("party/key2.png")); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java new file mode 100644 index 000000000..8264de5ea --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/FileServiceTest.java @@ -0,0 +1,171 @@ +package umc.cockple.demo.domain.file.service; + +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.dto.FileUploadDTO; +import umc.cockple.demo.domain.file.exception.GcsErrorCode; +import umc.cockple.demo.domain.file.exception.GcsException; +import umc.cockple.demo.global.enums.DomainType; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +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.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FileService 단위 테스트") +class FileServiceTest { + + @Mock + private Storage storage; + + private FileService fileService; + + private String bucketName = "test-bucket"; + private MockMultipartFile mockFile; + + @BeforeEach + void setUp() { + fileService = new FileService(storage); + ReflectionTestUtils.setField(fileService, "bucket", bucketName); + mockFile = new MockMultipartFile("file", "test.jpg", MediaType.IMAGE_JPEG_VALUE, "test image content".getBytes()); + } + + // ========== uploadFile ========== + @Nested + @DisplayName("uploadFile - 단일 파일 업로드 테스트") + class UploadFile { + + @Test + @DisplayName("정상적인 MultipartFile이 주어지면 GCS에 업로드하고 응답 DTO를 반환한다") + void success() { + given(storage.create(any(BlobInfo.class), any(byte[].class))).willReturn(mock(Blob.class)); + + FileUploadDTO.Response response = fileService.uploadFile(mockFile, DomainType.CHAT); + + assertThat(response).isNotNull(); + assertThat(response.fileKey()).startsWith("chat/"); + assertThat(response.fileKey()).endsWith(".jpg"); + assertThat(response.fileUrl()).isEqualTo("https://storage.googleapis.com/" + bucketName + "/" + response.fileKey()); + assertThat(response.originalFileName()).isEqualTo("test.jpg"); + verify(storage).create(any(BlobInfo.class), any(byte[].class)); + } + + @Test + @DisplayName("파일이 비어있으면 null을 반환한다") + void returnNullWhenFileIsEmpty() { + MockMultipartFile emptyFile = new MockMultipartFile("file", "empty.jpg", "image/jpeg", new byte[0]); + + FileUploadDTO.Response response = fileService.uploadFile(emptyFile, DomainType.CHAT); + + assertThat(response).isNull(); + verify(storage, never()).create(any(BlobInfo.class), any(byte[].class)); + } + + @Test + @DisplayName("StorageException 발생 시 FILE_UPLOAD_GCS_EXCEPTION이 발생한다") + void throwExceptionWhenGcsError() { + given(storage.create(any(BlobInfo.class), any(byte[].class))).willThrow(new StorageException(500, "GCS 에러")); + + GcsException exception = assertThrows(GcsException.class, () -> fileService.uploadFile(mockFile, DomainType.CHAT)); + + assertThat(exception.getCode()).isEqualTo(GcsErrorCode.FILE_UPLOAD_GCS_EXCEPTION); + } + } + + // ========== uploadFiles ========== + @Nested + @DisplayName("uploadFiles - 다중 파일 업로드 테스트") + class UploadFiles { + + @Test + @DisplayName("정상적인 파일 목록이 주어지면 업로드된 응답 DTO 리스트를 반환한다") + void success() { + MockMultipartFile mockFile2 = new MockMultipartFile("file", "test2.png", MediaType.IMAGE_PNG_VALUE, "content2".getBytes()); + given(storage.create(any(BlobInfo.class), any(byte[].class))).willReturn(mock(Blob.class)); + + List responses = fileService.uploadFiles(List.of(mockFile, mockFile2), DomainType.PARTY); + + assertThat(responses).hasSize(2); + assertThat(responses.get(0).originalFileName()).isEqualTo("test.jpg"); + assertThat(responses.get(1).originalFileName()).isEqualTo("test2.png"); + // 2번 호출됐는지 확인 + verify(storage, times(2)).create(any(BlobInfo.class), any(byte[].class)); + } + + @Test + @DisplayName("파일 리스트가 null이거나 비어있으면 빈 리스트를 반환한다") + void returnEmptyListWhenFilesAreEmpty() { + List responses = fileService.uploadFiles(List.of(), DomainType.PARTY); + + assertThat(responses).isEmpty(); + verify(storage, never()).create(any(BlobInfo.class), any(byte[].class)); + } + } + + // ========== delete ========== + @Nested + @DisplayName("delete - 파일 삭제 테스트") + class Delete { + + @Test + @DisplayName("유효한 fileKey가 주어지면 정상적으로 삭제를 수행한다") + void success() { + String fileKey = "chat/uuid-1234.jpg"; + BlobId expectedBlobId = BlobId.of(bucketName, fileKey); + given(storage.delete(expectedBlobId)).willReturn(true); + + fileService.delete(fileKey); + + verify(storage).delete(expectedBlobId); + } + } + + // ========== downloadFile ========== + @Nested + @DisplayName("downloadFile - 파일 다운로드(Blob 조회) 테스트") + class DownloadFile { + + @Test + @DisplayName("유효한 fileKey를 주면 GCS에서 Blob 객체를 반환한다") + void success() { + String fileKey = "chat/valid-key.jpg"; + Blob mockBlob = mock(Blob.class); + given(storage.get(BlobId.of(bucketName, fileKey))).willReturn(mockBlob); + + Blob result = fileService.downloadFile(fileKey); + + assertThat(result).isNotNull(); + assertThat(result).isEqualTo(mockBlob); + } + + @Test + @DisplayName("Blob이 존재하지 않으면 FILE_DELETE_EXCEPTION 예외가 발생한다") + void throwExceptionWhenBlobIsNull() { + String fileKey = "chat/not-found.jpg"; + given(storage.get(BlobId.of(bucketName, fileKey))).willReturn(null); + + GcsException exception = assertThrows(GcsException.class, () -> + fileService.downloadFile(fileKey) + ); + + assertThat(exception.getCode()).isEqualTo(GcsErrorCode.FILE_DELETE_EXCEPTION); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index 0e59bc2e0..f05ea1af0 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -10,7 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; -import umc.cockple.demo.domain.image.service.ImageService; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.global.enums.Gender; @@ -40,7 +40,7 @@ class MemberCommandServiceTest { @Mock private MemberAddrRepository memberAddrRepository; @Mock private ChatRoomMemberRepository chatRoomMemberRepository; @Mock private KakaoOauthService kakaoOauthService; - @Mock private ImageService imageService; + @Mock private FileService fileService; private Member normalMember; diff --git a/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java index 89fc4cc3d..bac1a0404 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java @@ -1,6 +1,7 @@ package umc.cockple.demo.support.fixture; import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.enums.ChatRoomMemberStatus; @@ -9,6 +10,8 @@ import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; +import java.util.List; + public class ChatFixture { public static ChatRoom createPartyChatRoom(Party party) { @@ -40,4 +43,14 @@ public static ChatRoomMember createJoinedMemberWithLastRead(ChatRoom chatRoom, M public static ChatMessage createTextMessage(ChatRoom chatRoom, Member sender, String content) { return ChatMessage.create(chatRoom, sender, content, MessageType.TEXT); } + + public static ChatMessage createImageMessage(ChatRoom chatRoom, Member sender, List files) { + ChatMessage message = ChatMessage.create(chatRoom, sender, null, MessageType.TEXT); + message.getChatMessageFiles().addAll(files); + return message; + } + + public static ChatMessageFile createChatMessageFile(ChatMessage message, String fileKey, int fileOrder, String originalFileName) { + return ChatMessageFile.create(message, fileKey, fileOrder, originalFileName, 1024L, "image/png"); + } } \ No newline at end of file From 3e0ec65b5ffeffb94b366eeba29cdee84c56b042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:02:03 +0900 Subject: [PATCH 04/20] =?UTF-8?q?[docs/#534]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80=20(#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\270\260\353\212\245-\352\265\254\355\230\204.md" | 2 +- ...\270\260\353\212\245-\354\210\230\354\240\225.md" | 2 +- ...\270\260\355\203\200-\354\210\230\354\240\225.md" | 2 +- ...254\270\354\204\234-\354\236\221\354\227\205-.md" | 2 +- ...4\230\244\353\245\230\354\210\230\354\240\225.md" | 2 +- ...\212\244\355\212\270-\354\236\221\354\204\261.md" | 12 ++++++++++++ 6 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 ".github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" diff --git "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md" "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md" index 8b6df1b8f..8d7fa8bdd 100644 --- "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md" +++ "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\352\265\254\355\230\204.md" @@ -1,7 +1,7 @@ --- name: 기능 구현 about: '기능 설명 및 구현 ' -title: "[FEAT]" +title: "[FEAT] " labels: "\U0001F6E0️ FEAT" assignees: '' diff --git "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md" index 63fc142d0..9756738e8 100644 --- "a/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md" +++ "b/.github/ISSUE_TEMPLATE/\352\270\260\353\212\245-\354\210\230\354\240\225.md" @@ -1,7 +1,7 @@ --- name: 기능 수정 about: 리팩토링 목적이 아닌 기능 수정 -title: "[FIX} " +title: "[FIX] " labels: "\U0001F527 FIX" assignees: '' diff --git "a/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md" index 2174d7f49..5f9b0f0ac 100644 --- "a/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md" +++ "b/.github/ISSUE_TEMPLATE/\352\270\260\355\203\200-\354\210\230\354\240\225.md" @@ -1,7 +1,7 @@ --- name: 기타 수정 about: '작은 부분 수정 ' -title: "[CHORE]" +title: "[CHORE] " labels: "\U0001F3B5 CHORE" assignees: '' diff --git "a/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md" "b/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md" index 5a0eeeee4..b8622ce41 100644 --- "a/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md" +++ "b/.github/ISSUE_TEMPLATE/\353\254\270\354\204\234-\354\236\221\354\227\205-.md" @@ -1,7 +1,7 @@ --- name: '문서 작업 ' about: 문서작업 내용 -title: '' +title: '[Docs] ' labels: "\U0001F4DC DOC" assignees: '' diff --git "a/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md" "b/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md" index 7fca801cc..1c5c4a404 100644 --- "a/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md" +++ "b/.github/ISSUE_TEMPLATE/\354\230\244\353\245\230\354\210\230\354\240\225.md" @@ -1,7 +1,7 @@ --- name: 오류수정 about: '오류 설명 및 수정 ' -title: "[DEBUG]" +title: "[DEBUG] " labels: "\U0001F577️ BUG" assignees: '' diff --git "a/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" "b/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" new file mode 100644 index 000000000..2421ef851 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" @@ -0,0 +1,12 @@ +--- +name: '테스트 작성' +about: 테스트 코드 작성 +title: '[Test] ' +labels: "\U00530069 TEST" +assignees: '' + +--- + +## 🔑 테스트 내용 + +
From d9343e6c491f1a56ac61f2604463fdb89f42dd8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:39:12 +0900 Subject: [PATCH 05/20] =?UTF-8?q?[chore/#536]=20FCM=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20firebase=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: firebase account 설정파일 주입 * chore: ci, cd에 firebase 주입 경로 추가 * chore: build.gradle에 의존성 추가 --- .github/workflows/cd.yml | 6 ++++++ .github/workflows/ci.yml | 5 +++++ .gitignore | 5 ++++- build.gradle | 3 +++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a25b79bbc..578dbebe7 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -27,6 +27,12 @@ jobs: else echo "DOCKER_TAG=staging" >> $GITHUB_OUTPUT fi + + - name: Create Firebase Key File + run: | + mkdir -p src/main/resources/firebase + echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}' > src/main/resources/firebase/cockple-fcm-firebase-adminsdk-fbsvc-5cac44bb029.json + - name: Build with Gradle run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f0cb4c48..46dd991d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,11 @@ jobs: echo "${{ secrets.APPLICATION_STAGING }}" > src/main/resources/application.yml fi + - name: Create Firebase Key File + run: | + mkdir -p src/main/resources/firebase + echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}' > src/main/resources/firebase/cockple-fcm-firebase-adminsdk-fbsvc-5cac44bb029.json + - name: Grant execute permission for Gradle run: chmod +x gradlew diff --git a/.gitignore b/.gitignore index 7b042e06d..69585293d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ application-dev.yml terraform/.terraform/ terraform/terraform.tfstate terraform/terraform.tfstate.backup -terraform/terraform.tfvars \ No newline at end of file +terraform/terraform.tfvars + +### firebase ### +src/main/resources/firebase/*.json \ No newline at end of file diff --git a/build.gradle b/build.gradle index 57828e982..563ba82de 100644 --- a/build.gradle +++ b/build.gradle @@ -95,6 +95,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.lettuce:lettuce-core' + + // firebase + implementation 'com.google.firebase:firebase-admin:9.7.1' } tasks.named('test') { From faba49e2a5830c8922312f1b0415735b25531f77 Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 12 Mar 2026 02:34:42 +0900 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EB=9D=BC=EB=B2=A8=EB=A7=81=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" "b/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" index 2421ef851..5cf57e0eb 100644 --- "a/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" +++ "b/.github/ISSUE_TEMPLATE/\355\205\214\354\212\244\355\212\270-\354\236\221\354\204\261.md" @@ -2,7 +2,7 @@ name: '테스트 작성' about: 테스트 코드 작성 title: '[Test] ' -labels: "\U00530069 TEST" +labels: "🧪 TEST" assignees: '' --- From 141c4eebbd63d90e90b5f14592d1aef84b01a3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:09:30 +0900 Subject: [PATCH 07/20] =?UTF-8?q?[test/#516]=20Member=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Member 통합테스트 구현 * feat: Member 통합테스트 구현 * feat: 모든 필드를 커버하는 통합테스트 추가 * feat: 모든 필드를 커버하는 통합테스트 추가 * test: 중복되는 프로필 테스트 제거 --- .../member/controller/MemberController.java | 2 +- .../member/dto/GetProfileResponseDTO.java | 52 +- .../integration/MemberIntegrationTest.java | 626 ++++++++++++++++ .../service/MemberCommandServiceTest.java | 684 +++++++++++++++++- .../service/MemberQueryServiceTest.java | 344 +++++++++ .../support/fixture/MemberAddrFixture.java | 115 +++ 6 files changed, 1794 insertions(+), 29 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java diff --git a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java index 70a5a53cf..55d1150d0 100644 --- a/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java +++ b/src/main/java/umc/cockple/demo/domain/member/controller/MemberController.java @@ -91,7 +91,7 @@ public BaseResponse issueOtherDevToken() { @PostMapping("/my/details") @Operation(summary = "로그인 후 상세 정보 받기 API", description = "로그인 후 추가적인 상세 정보를 받습니다.") - public BaseResponse memberDetailInfo(@RequestBody @Valid MemberDetailInfoRequestDTO requestDTO) { + public BaseResponse registerMemberDetailInfo(@RequestBody @Valid MemberDetailInfoRequestDTO requestDTO) { Long memberId = SecurityUtil.getCurrentMemberId(); diff --git a/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java b/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java index 77bfa1dec..8175f32c2 100644 --- a/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java +++ b/src/main/java/umc/cockple/demo/domain/member/dto/GetProfileResponseDTO.java @@ -1,26 +1,26 @@ -package umc.cockple.demo.domain.member.dto; - -import jakarta.validation.constraints.NotBlank; -import lombok.Builder; -import umc.cockple.demo.domain.member.domain.MemberKeyword; -import umc.cockple.demo.global.enums.Gender; -import umc.cockple.demo.global.enums.Keyword; -import umc.cockple.demo.global.enums.Level; - -import java.time.LocalDate; -import java.util.List; - -@Builder -public record GetProfileResponseDTO( - String memberName, - LocalDate birth, - Gender gender, - Level level, - String profileImgUrl, - Integer myPartyCnt, - Integer myGoldMedalCnt, - Integer mySilverMedalCnt, - Integer myBronzeMedalCnt - -) { -} +package umc.cockple.demo.domain.member.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import umc.cockple.demo.domain.member.domain.MemberKeyword; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Keyword; +import umc.cockple.demo.global.enums.Level; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record GetProfileResponseDTO( + String memberName, + LocalDate birth, + Gender gender, + Level level, + String profileImgUrl, + Integer myPartyCnt, + Integer myGoldMedalCnt, + Integer mySilverMedalCnt, + Integer myBronzeMedalCnt + +) { +} diff --git a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java new file mode 100644 index 000000000..6bedc6309 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java @@ -0,0 +1,626 @@ +package umc.cockple.demo.domain.member.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.contest.domain.Contest; +import umc.cockple.demo.domain.contest.enums.MedalType; +import umc.cockple.demo.domain.contest.repository.ContestRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.MemberKeyword; +import umc.cockple.demo.domain.member.domain.ProfileImg; +import umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberKeywordRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.exercise.enums.ExerciseMemberShipStatus; +import umc.cockple.demo.domain.party.enums.ParticipationType; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Keyword; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.global.oauth2.service.KakaoOauthService; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class MemberIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired MemberRepository memberRepository; + @Autowired MemberAddrRepository memberAddrRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + + // withdrawMember에서 카카오 연결 끊기 API 호출을 막기 위해 Mock 처리 + @MockitoBean + KakaoOauthService kakaoOauthService; + + @Autowired ContestRepository contestRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired MemberKeywordRepository memberKeywordRepository; + + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createMember("홍길동", Gender.MALE, Level.A, 1001L)); + } + + @AfterEach + void tearDown() { + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); // cascade: MemberAddr, MemberKeyword 등 함께 삭제 + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("PATCH /api/member - 회원 탈퇴") + class WithdrawMember { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 일반 멤버가 탈퇴하면 성공한다") + void normalMember_withdrawSuccess() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/member")) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("400 - 모임장은 탈퇴할 수 없다") + void manager_cannotWithdraw() throws Exception { + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.party_MANAGER)); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/member")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MANAGER_CANNOT_LEAVE.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MANAGER_CANNOT_LEAVE.getMessage())); + } + + @Test + @DisplayName("400 - 부모임장은 탈퇴할 수 없다") + void subManager_cannotWithdraw() throws Exception { + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.party_SUBMANAGER)); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/member")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.SUBMANAGER_CANNOT_LEAVE.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.SUBMANAGER_CANNOT_LEAVE.getMessage())); + } + } + } + + + @Nested + @DisplayName("GET /api/profile/{memberId} - 타인 프로필 조회") + class GetProfile { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 모든 필드가 정상 반환된다") + void getProfile_모든_필드가_정상_반환된다() throws Exception { + // given + Member freshMember = memberRepository.save(Member.builder() + .memberName("홍길동") + .nickname("홍길동") + .gender(Gender.MALE) + .birth(LocalDate.of(1990, 1, 1)) + .level(Level.A) + .isActive(MemberStatus.ACTIVE) + .socialId(9001L) + .build()); + + // ProfileImg: Member cascade를 통해 저장 + ProfileImg profileImg = ProfileImg.builder() + .member(freshMember) + .imgKey("profile/test-key.jpg") + .build(); + freshMember.updateProfileImg(profileImg); + memberRepository.save(freshMember); + + // 금2, 은1, 동1 + for (int i = 0; i < 2; i++) { + contestRepository.save(Contest.builder() + .member(freshMember) + .contestName("금메달 대회") + .medalType(MedalType.GOLD) + .type(ParticipationType.SINGLE) + .level(Level.A) + .contentIsOpen(true) + .videoIsOpen(false) + .build()); + } + contestRepository.save(Contest.builder() + .member(freshMember) + .contestName("은메달 대회") + .medalType(MedalType.SILVER) + .type(ParticipationType.SINGLE) + .level(Level.A) + .contentIsOpen(true) + .videoIsOpen(false) + .build()); + contestRepository.save(Contest.builder() + .member(freshMember) + .contestName("동메달 대회") + .medalType(MedalType.BRONZE) + .type(ParticipationType.SINGLE) + .level(Level.A) + .contentIsOpen(true) + .videoIsOpen(false) + .build()); + + // 모임 2개 + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); + + SecurityContextHelper.setAuthentication(freshMember.getId(), freshMember.getNickname()); + + // when & then + mockMvc.perform(get("/api/profile/{memberId}", freshMember.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").value("홍길동")) + .andExpect(jsonPath("$.data.birth").value("1990-01-01")) + .andExpect(jsonPath("$.data.gender").value("MALE")) + .andExpect(jsonPath("$.data.level").value("A")) + .andExpect(jsonPath("$.data.profileImgUrl").value("https://storage.googleapis.com/test-bucket/profile/test-key.jpg")) + .andExpect(jsonPath("$.data.myPartyCnt").value(2)) + .andExpect(jsonPath("$.data.myGoldMedalCnt").value(2)) + .andExpect(jsonPath("$.data.mySilverMedalCnt").value(1)) + .andExpect(jsonPath("$.data.myBronzeMedalCnt").value(1)); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 멤버 조회 시 MEMBER_NOT_FOUND 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/profile/{memberId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + + + @Nested + @DisplayName("GET /api/my/profile - 내 프로필 조회") + class GetMyProfile { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 대표 주소가 있으면 내 프로필이 반환된다") + void getMyProfile_success() throws Exception { + memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/my/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").value("홍길동")) + .andExpect(jsonPath("$.data.addr3").value("역삼동")) + .andExpect(jsonPath("$.data.myExerciseCnt").value(0)); + } + + @Test + @DisplayName("200 - 모든 필드가 정상 반환된다") + void getMyProfile_모든_필드가_정상_반환된다() throws Exception { + // given + Member freshMember = memberRepository.save(Member.builder() + .memberName("홍길동") + .nickname("홍길동") + .gender(Gender.MALE) + .birth(LocalDate.of(1990, 1, 1)) + .level(Level.A) + .isActive(MemberStatus.ACTIVE) + .socialId(9002L) + .build()); + + memberAddrRepository.save(MemberAddrFixture.createMainAddr(freshMember)); + + // ProfileImg: Member cascade를 통해 저장 + ProfileImg profileImg = ProfileImg.builder() + .member(freshMember) + .imgKey("profile/test-key.jpg") + .build(); + freshMember.updateProfileImg(profileImg); + memberRepository.save(freshMember); + + // 금2, 은1, 동1 + for (int i = 0; i < 2; i++) { + contestRepository.save(Contest.builder() + .member(freshMember) + .contestName("금메달 대회") + .medalType(MedalType.GOLD) + .type(ParticipationType.SINGLE) + .level(Level.A) + .contentIsOpen(true) + .videoIsOpen(false) + .build()); + } + contestRepository.save(Contest.builder() + .member(freshMember) + .contestName("은메달 대회") + .medalType(MedalType.SILVER) + .type(ParticipationType.SINGLE) + .level(Level.A) + .contentIsOpen(true) + .videoIsOpen(false) + .build()); + contestRepository.save(Contest.builder() + .member(freshMember) + .contestName("동메달 대회") + .medalType(MedalType.BRONZE) + .type(ParticipationType.SINGLE) + .level(Level.A) + .contentIsOpen(true) + .videoIsOpen(false) + .build()); + + // 모임 2개, 운동 2개, 키워드 2개 + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); + + memberExerciseRepository.save(MemberExercise.builder() + .member(freshMember) + .exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER) + .build()); + memberExerciseRepository.save(MemberExercise.builder() + .member(freshMember) + .exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER) + .build()); + + memberKeywordRepository.save(MemberKeyword.builder() + .member(freshMember) + .keyword(Keyword.FRIENDSHIP) + .build()); + memberKeywordRepository.save(MemberKeyword.builder() + .member(freshMember) + .keyword(Keyword.FREE) + .build()); + + SecurityContextHelper.setAuthentication(freshMember.getId(), freshMember.getNickname()); + + // when & then + mockMvc.perform(get("/api/my/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").value("홍길동")) + .andExpect(jsonPath("$.data.birth").value("1990-01-01")) + .andExpect(jsonPath("$.data.gender").value("MALE")) + .andExpect(jsonPath("$.data.level").value("A")) + .andExpect(jsonPath("$.data.keywords", hasSize(2))) + .andExpect(jsonPath("$.data.addr3").value("역삼동")) + .andExpect(jsonPath("$.data.streetAddr").value("테헤란로 123")) + .andExpect(jsonPath("$.data.buildingName").value("ㅁㅁ빌딩")) + .andExpect(jsonPath("$.data.latitude").value(37.5)) + .andExpect(jsonPath("$.data.longitude").value(127.0)) + .andExpect(jsonPath("$.data.profileImgUrl").value("https://storage.googleapis.com/test-bucket/profile/test-key.jpg")) + .andExpect(jsonPath("$.data.myPartyCnt").value(2)) + .andExpect(jsonPath("$.data.myExerciseCnt").value(2)) + .andExpect(jsonPath("$.data.myGoldMedalCnt").value(2)) + .andExpect(jsonPath("$.data.mySilverMedalCnt").value(1)) + .andExpect(jsonPath("$.data.myBronzeMedalCnt").value(1)); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("400 - 대표 주소가 없으면 MAIN_ADDRESS_NULL 에러를 반환한다") + void noMainAddress_fail() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/my/profile")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + } + } + + + @Nested + @DisplayName("POST /api/my/profile/locations - 주소 추가") + class AddAddress { + + private CreateMemberAddrDTO.CreateMemberAddrRequestDTO validRequest; + + @BeforeEach + void setUp() { + validRequest = new CreateMemberAddrDTO.CreateMemberAddrRequestDTO( + "서울특별시", "강남구", "역삼동", "테헤란로 123", "ㅁㅁ빌딩", 37.5, 127.0); + } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 새 주소를 추가하면 memberAddrId를 반환한다") + void addAddress_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/my/profile/locations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberAddrId").isNumber()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("400 - 같은 주소를 중복 추가하면 DUPLICATE_ADDRESS 에러를 반환한다") + void duplicateAddress_fail() throws Exception { + memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); // validRequest와 동일한 값 + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/my/profile/locations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.DUPLICATE_ADDRESS.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.DUPLICATE_ADDRESS.getMessage())); + } + + @Test + @DisplayName("400 - 주소가 이미 5개면 OVER_NUMBER_OF_ADDR 에러를 반환한다") + void overNumberOfAddr_fail() throws Exception { + // validRequest(강남구)와 addr2가 달라 중복 검사를 통과하도록 마포구 주소 5개 생성 + for (int i = 1; i <= 5; i++) { + memberAddrRepository.save(MemberAddrFixture.createAddr(member, "동" + i, "길로 " + i, i == 1)); + } + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/my/profile/locations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.OVER_NUMBER_OF_ADDR.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.OVER_NUMBER_OF_ADDR.getMessage())); + } + } + } + + + @Nested + @DisplayName("PATCH /api/my/profile/locations/{memberAddrId} - 대표 주소 변경") + class UpdateMainAddress { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 비대표 주소로 대표 주소를 변경하면 성공한다") + void updateMainAddress_success() throws Exception { + memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + MemberAddr subAddr = memberAddrRepository.save(MemberAddrFixture.createSubAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/my/profile/locations/{memberAddrId}", subAddr.getId())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 주소 ID로 변경하면 ADDRESS_NOT_FOUND 에러를 반환한다") + void addressNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/my/profile/locations/{memberAddrId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.ADDRESS_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.ADDRESS_NOT_FOUND.getMessage())); + } + } + } + + + @Nested + @DisplayName("DELETE /api/my/profile/locations/{memberAddrId} - 주소 삭제") + class DeleteAddress { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 비대표 주소를 삭제하면 성공한다") + void deleteSubAddress_success() throws Exception { + memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + MemberAddr subAddr = memberAddrRepository.save(MemberAddrFixture.createSubAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/my/profile/locations/{memberAddrId}", subAddr.getId())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("400 - 대표 주소는 삭제할 수 없다") + void cannotRemoveMainAddress() throws Exception { + MemberAddr mainAddr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + memberAddrRepository.save(MemberAddrFixture.createSubAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/my/profile/locations/{memberAddrId}", mainAddr.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.CANNOT_REMOVE_MAIN_ADDR.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.CANNOT_REMOVE_MAIN_ADDR.getMessage())); + } + + @Test + @DisplayName("400 - 주소가 1개뿐일 때 삭제하면 MEMBER_ADDRESS_MINIMUM_REQUIRED 에러를 반환한다") + void minimumAddressRequired() throws Exception { + // 비대표 주소 1개만 존재: isMain=false 이므로 첫 번째 체크(대표주소 여부)를 통과하고 + // 두 번째 체크(1개 이하)에서 예외 발생 + MemberAddr subAddr = memberAddrRepository.save(MemberAddrFixture.createSubAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/my/profile/locations/{memberAddrId}", subAddr.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED.getMessage())); + } + } + } + + + @Nested + @DisplayName("GET /api/my/location - 현재 위치 조회") + class GetNowAddress { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 대표 주소가 있으면 현재 위치를 반환한다") + void getNowAddress_success() throws Exception { + memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/my/location")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberAddrId").isNumber()) + .andExpect(jsonPath("$.data.addr3").value("역삼동")) + .andExpect(jsonPath("$.data.streetAddr").value("테헤란로 123")) + .andExpect(jsonPath("$.data.buildingName").value("ㅁㅁ빌딩")); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("400 - 대표 주소가 없으면 MAIN_ADDRESS_NULL 에러를 반환한다") + void noMainAddress_fail() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/my/location")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + } + } + + // ===================================================== + // GET /api/my/profile/locations - 전체 주소 조회 + // ===================================================== + + @Nested + @DisplayName("GET /api/my/profile/locations - 전체 주소 조회") + class GetAllAddress { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 전체 주소를 조회하면 대표 주소가 먼저 반환된다") + void getAllAddress_mainFirst() throws Exception { + memberAddrRepository.save(MemberAddrFixture.createSubAddr(member)); + memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/my/profile/locations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].isMainAddr").value(true)) + .andExpect(jsonPath("$.data[1].isMainAddr").value(false)); + } + + @Test + @DisplayName("200 - 주소의 모든 필드가 정상 반환된다") + void getAllAddress_모든_필드가_정상_반환된다() throws Exception { + // given + MemberAddr mainAddr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(member)); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // when & then + mockMvc.perform(get("/api/my/profile/locations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].addrId").value(mainAddr.getId())) + .andExpect(jsonPath("$.data[0].addr1").value("서울특별시")) + .andExpect(jsonPath("$.data[0].addr2").value("강남구")) + .andExpect(jsonPath("$.data[0].addr3").value("역삼동")) + .andExpect(jsonPath("$.data[0].streetAddr").value("테헤란로 123")) + .andExpect(jsonPath("$.data[0].buildingName").value("ㅁㅁ빌딩")) + .andExpect(jsonPath("$.data[0].latitude").value(37.5)) + .andExpect(jsonPath("$.data[0].longitude").value(127.0)) + .andExpect(jsonPath("$.data[0].isMainAddr").value(true)); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index f05ea1af0..c197bc16a 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -12,20 +12,39 @@ import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.domain.ProfileImg; +import umc.cockple.demo.domain.member.dto.MemberDetailInfoRequestDTO; +import umc.cockple.demo.domain.member.dto.UpdateProfileRequestDTO; +import umc.cockple.demo.domain.member.enums.MemberPartyStatus; +import umc.cockple.demo.domain.member.enums.MemberStatus; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Keyword; import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; import umc.cockple.demo.global.oauth2.service.KakaoOauthService; +import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; +import static umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO.*; + @ExtendWith(MockitoExtension.class) @DisplayName("MemberCommandService") class MemberCommandServiceTest { @@ -39,17 +58,530 @@ class MemberCommandServiceTest { @Mock private MemberKeywordRepository memberKeywordRepository; @Mock private MemberAddrRepository memberAddrRepository; @Mock private ChatRoomMemberRepository chatRoomMemberRepository; - @Mock private KakaoOauthService kakaoOauthService; @Mock private FileService fileService; + @Mock private KakaoOauthService kakaoOauthService; private Member normalMember; @BeforeEach void setUp() { - normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 9001L); + normalMember = MemberFixture.createMember("와나", Gender.MALE, Level.C, 9001L); ReflectionTestUtils.setField(normalMember, "id", 1L); } + @Nested + @DisplayName("registerMemberDetailInfo") + class RegisterMemberDetailInfo { + + private MemberDetailInfoRequestDTO requestWithImg; + private MemberDetailInfoRequestDTO requestWithoutImg; + + @BeforeEach + void setUp() { + requestWithImg = MemberDetailInfoRequestDTO.builder() + .memberName("강와나") + .gender(Gender.MALE) + .birth(LocalDate.of(2002, 4, 2)) + .level(Level.A) + .imgKey("profile/test-key.jpg") + .keywords(List.of(Keyword.FRIENDSHIP, Keyword.FREE)) + .build(); + + requestWithoutImg = MemberDetailInfoRequestDTO.builder() + .memberName("강와나") + .gender(Gender.MALE) + .birth(LocalDate.of(2002, 4, 2)) + .level(Level.A) + .imgKey(null) + .keywords(List.of(Keyword.FRIENDSHIP)) + .build(); + } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("imgKey가_있으면_ProfileImg와_함께_회원정보가_업데이트된다") + void imgKey가_있으면_ProfileImg와_함께_회원정보가_업데이트된다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.memberDetailInfo(normalMember.getId(), requestWithImg); + + // then + then(memberKeywordRepository).should().saveAll(any()); + + assertThat(normalMember.getMemberName()).isEqualTo("강와나"); + assertThat(normalMember.getGender()).isEqualTo(Gender.MALE); + assertThat(normalMember.getBirth()).isEqualTo(LocalDate.of(2002, 4, 2)); + assertThat(normalMember.getLevel()).isEqualTo(Level.A); + assertThat(normalMember.getProfileImg()).isNotNull(); + assertThat(normalMember.getProfileImg().getImgKey()).isEqualTo("profile/test-key.jpg"); + } + + @Test + @DisplayName("imgKey가_없으면_ProfileImg_없이_회원정보가_업데이트된다") + void imgKey가_없으면_ProfileImg_없이_회원정보가_업데이트된다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.memberDetailInfo(normalMember.getId(), requestWithoutImg); + + // then + then(memberKeywordRepository).should().saveAll(any()); + + assertThat(normalMember.getMemberName()).isEqualTo("강와나"); + assertThat(normalMember.getLevel()).isEqualTo(Level.A); + assertThat(normalMember.getProfileImg()).isNull(); + } + + @Test + @DisplayName("keywords가_정상적으로_저장된다") + void keywords가_정상적으로_저장된다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.memberDetailInfo(normalMember.getId(), requestWithImg); + + // then + // saveAll 호출 시 keywords 개수가 request와 동일한지 검증 + assertThat(normalMember.getKeywords()).hasSize(requestWithImg.keywords().size()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + memberCommandService.memberDetailInfo(999L, requestWithImg)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("updateProfile") + class UpdateProfile { + + private UpdateProfileRequestDTO requestWithImg; + private UpdateProfileRequestDTO requestWithoutImg; + + @BeforeEach + void setUp() { + requestWithImg = new UpdateProfileRequestDTO( + "강와나", LocalDate.of(2002, 4, 2), Level.A, + List.of(Keyword.FRIENDSHIP, Keyword.FREE), "profile/new-key.jpg"); + + requestWithoutImg = new UpdateProfileRequestDTO( + "강와나", LocalDate.of(2002, 4, 2), Level.A, + List.of(Keyword.FRIENDSHIP), null); + } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("imgKey가_없으면_이미지_없이_프로필이_업데이트된다") + void imgKey가_없으면_이미지_없이_프로필이_업데이트된다() { + // given + given(memberRepository.findMemberWithProfileById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) + .willReturn(List.of()); + + // when + memberCommandService.updateProfile(requestWithoutImg, normalMember.getId()); + + // then + then(memberKeywordRepository).should().deleteAllByMember(normalMember); + then(memberKeywordRepository).should().saveAll(any()); + assertThat(normalMember.getMemberName()).isEqualTo("강와나"); + assertThat(normalMember.getLevel()).isEqualTo(Level.A); + } + + @Test + @DisplayName("기존_이미지가_없고_imgKey가_있으면_새_ProfileImg를_생성해_업데이트된다") + void 기존_이미지가_없고_imgKey가_있으면_새_ProfileImg를_생성해_업데이트된다() { + // given + given(memberRepository.findMemberWithProfileById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) + .willReturn(List.of()); + + // when + memberCommandService.updateProfile(requestWithImg, normalMember.getId()); + + // then + assertThat(normalMember.getProfileImg()).isNotNull(); + assertThat(normalMember.getProfileImg().getImgKey()).isEqualTo("profile/new-key.jpg"); + } + + @Test + @DisplayName("기존_이미지가_있고_imgKey가_다르면_기존_이미지를_삭제하고_업데이트된다") + void 기존_이미지가_있고_imgKey가_다르면_기존_이미지를_삭제하고_업데이트된다() { + // given + ProfileImg existingProfile = ProfileImg.builder() + .member(normalMember) + .imgKey("profile/old-key.jpg") + .build(); + ReflectionTestUtils.setField(normalMember, "profileImg", existingProfile); + + given(memberRepository.findMemberWithProfileById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) + .willReturn(List.of()); + + // when + memberCommandService.updateProfile(requestWithImg, normalMember.getId()); + + // then + then(fileService).should().delete("profile/old-key.jpg"); + } + + @Test + @DisplayName("기존_이미지가_있고_imgKey가_같으면_삭제_없이_업데이트된다") + void 기존_이미지가_있고_imgKey가_같으면_삭제_없이_업데이트된다() { + // given + UpdateProfileRequestDTO sameImgRequest = new UpdateProfileRequestDTO( + "강와나", LocalDate.of(2002, 4, 2), Level.A, + List.of(Keyword.FRIENDSHIP), "profile/same-key.jpg"); + + ProfileImg existingProfile = ProfileImg.builder() + .member(normalMember) + .imgKey("profile/same-key.jpg") + .build(); + ReflectionTestUtils.setField(normalMember, "profileImg", existingProfile); + + given(memberRepository.findMemberWithProfileById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) + .willReturn(List.of()); + + // when + memberCommandService.updateProfile(sameImgRequest, normalMember.getId()); + + // then + then(fileService).should(never()).delete(any()); + } + + @Test + @DisplayName("기존_키워드를_삭제하고_새_키워드를_저장한다") + void 기존_키워드를_삭제하고_새_키워드를_저장한다() { + // given + given(memberRepository.findMemberWithProfileById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) + .willReturn(List.of()); + + // when + memberCommandService.updateProfile(requestWithImg, normalMember.getId()); + + // then + then(memberKeywordRepository).should().deleteAllByMember(normalMember); + then(memberKeywordRepository).should().saveAll(any()); + assertThat(normalMember.getKeywords()).hasSize(requestWithImg.keywords().size()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findMemberWithProfileById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberCommandService.updateProfile(requestWithImg, 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("addMemberNewAddr") + class AddMemberNewAddr { + + private CreateMemberAddrRequestDTO requestDto; + + @BeforeEach + void setUp() { + requestDto = new CreateMemberAddrRequestDTO( + "서울특별시", "강남구", "역삼동", + "테헤란로 123", "테스트빌딩", + 37.5, 127.0); + } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("정상적으로_주소를_등록하면_새_주소_ID를_반환한다") + void 정상적으로_주소를_등록하면_새_주소_ID를_반환한다() { + // given + MemberAddr savedAddr = MemberAddrFixture.createSeoulAddr(normalMember, true); + ReflectionTestUtils.setField(savedAddr, "id", 10L); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.save(any())).willReturn(savedAddr); + + // when + CreateMemberAddrResponseDTO response = + memberCommandService.addMemberNewAddr(requestDto, normalMember.getId()); + + // then + assertThat(response.memberAddrId()).isEqualTo(10L); + } + + @Test + @DisplayName("기존_대표주소가_있으면_대표주소가_false로_해제된다") + void 기존_대표주소가_있으면_대표주소가_false로_해제된다() { + // given + MemberAddr existingMainAddr = MemberAddrFixture.createBusanAddr(normalMember, true); + normalMember.getAddresses().add(existingMainAddr); + + MemberAddr newAddr = MemberAddrFixture.createSeoulAddr(normalMember, true); + ReflectionTestUtils.setField(newAddr, "id", 11L); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.save(any())).willReturn(newAddr); + + // when + memberCommandService.addMemberNewAddr(requestDto, normalMember.getId()); + + // then + assertThat(existingMainAddr.getIsMain()).isFalse(); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberCommandService.addMemberNewAddr(requestDto, 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("중복_주소이면_DUPLICATE_ADDRESS_예외를_던진다") + void 중복_주소이면_DUPLICATE_ADDRESS_예외를_던진다() { + // given + // requestDto(서울/강남/역삼/테헤란로 123/테스트빌딩/37.5/127.0)와 동일한 주소를 미리 등록 + MemberAddr existingAddr = MemberAddrFixture.createSeoulAddr(normalMember, true); + normalMember.getAddresses().add(existingAddr); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when & then + assertThatThrownBy(() -> + memberCommandService.addMemberNewAddr(requestDto, normalMember.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.DUPLICATE_ADDRESS); + } + + @Test + @DisplayName("주소가_5개_이상이면_OVER_NUMBER_OF_ADDR_예외를_던진다") + void 주소가_5개_이상이면_OVER_NUMBER_OF_ADDR_예외를_던진다() { + // given + for (int i = 0; i < 5; i++) { + normalMember.getAddresses().add( + MemberAddrFixture.createAddrWithIndex(normalMember, i, i == 0)); + } + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when & then + assertThatThrownBy(() -> + memberCommandService.addMemberNewAddr(requestDto, normalMember.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.OVER_NUMBER_OF_ADDR); + } + } + } + + @Nested + @DisplayName("updateMainAddr") + class UpdateMainAddr { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("기존_대표주소가_해제되고_새_대표주소가_설정된다") + void 기존_대표주소가_해제되고_새_대표주소가_설정된다() { + // given + MemberAddr oldMainAddr = MemberAddrFixture.createBusanAddr(normalMember, true); + normalMember.getAddresses().add(oldMainAddr); + + MemberAddr newMainAddr = MemberAddrFixture.createSeoulAddr(normalMember, false); + ReflectionTestUtils.setField(newMainAddr, "id", 20L); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.findById(20L)) + .willReturn(Optional.of(newMainAddr)); + + // when + memberCommandService.updateMainAddr(normalMember.getId(), 20L); + + // then + assertThat(oldMainAddr.getIsMain()).isFalse(); + assertThat(newMainAddr.getIsMain()).isTrue(); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다") + void 존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + memberCommandService.updateMainAddr(normalMember.getId(), 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.ADDRESS_NOT_FOUND); + } + } + } + + // ===================================================================== + // deleteMemberAddr + // ===================================================================== + + @Nested + @DisplayName("deleteMemberAddr") + class DeleteMemberAddr { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("대표주소가_아닌_주소를_삭제하면_정상_삭제된다") + void 대표주소가_아닌_주소를_삭제하면_정상_삭제된다() { + // given + MemberAddr mainAddr = MemberAddrFixture.createMainAddr(normalMember); + MemberAddr subAddr = MemberAddrFixture.createSubAddr(normalMember); + ReflectionTestUtils.setField(subAddr, "id", 30L); + normalMember.getAddresses().add(mainAddr); + normalMember.getAddresses().add(subAddr); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.findById(30L)).willReturn(Optional.of(subAddr)); + + // when + memberCommandService.deleteMemberAddr(normalMember.getId(), 30L); + + // then + then(memberAddrRepository).should().deleteById(30L); + assertThat(normalMember.getAddresses()).doesNotContain(subAddr); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + + @Test + @DisplayName("존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다") + void 존재하지_않는_주소이면_ADDRESS_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> + memberCommandService.deleteMemberAddr(normalMember.getId(), 999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.ADDRESS_NOT_FOUND); + } + + @Test + @DisplayName("대표주소를_삭제하면_CANNOT_REMOVE_MAIN_ADDR_예외를_던진다") + void 대표주소를_삭제하면_CANNOT_REMOVE_MAIN_ADDR_예외를_던진다() { + // given + MemberAddr mainAddr = MemberAddrFixture.createMainAddr(normalMember); + ReflectionTestUtils.setField(mainAddr, "id", 31L); + normalMember.getAddresses().add(mainAddr); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.findById(31L)).willReturn(Optional.of(mainAddr)); + + // when & then + assertThatThrownBy(() -> + memberCommandService.deleteMemberAddr(normalMember.getId(), 31L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.CANNOT_REMOVE_MAIN_ADDR); + } + + @Test + @DisplayName("주소가_1개이면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다") + void 주소가_1개이면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다() { + // given + MemberAddr onlySubAddr = MemberAddrFixture.createSubAddr(normalMember); + ReflectionTestUtils.setField(onlySubAddr, "id", 32L); + normalMember.getAddresses().add(onlySubAddr); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(memberAddrRepository.findById(32L)).willReturn(Optional.of(onlySubAddr)); + + // when & then + assertThatThrownBy(() -> + memberCommandService.deleteMemberAddr(normalMember.getId(), 32L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED); + } + } + } + @Nested @DisplayName("withdrawMember") class WithdrawMember { @@ -69,5 +601,153 @@ class WithdrawMember { then(memberExerciseRepository).should(never()) .deleteAll(); } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("미래_운동만_삭제되고_모임과_키워드도_삭제된다") + void 미래_운동만_삭제되고_모임과_키워드도_삭제된다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.withdrawMember(normalMember.getId()); + + // then + then(memberExerciseRepository).should() + .deleteFutureExercisesByMember(eq(normalMember), any(), any()); + then(memberExerciseRepository).should(never()).deleteAll(); + then(memberPartyRepository).should().deleteAllByMember(normalMember); + then(memberKeywordRepository).should().deleteAllByMember(normalMember); + } + + @Test + @DisplayName("탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다") + void 탈퇴_후_회원_상태가_INACTIVE가_되고_refreshToken이_null이_된다() { + // given + normalMember.setRefreshToken("existing-refresh-token"); + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.withdrawMember(normalMember.getId()); + + // then + assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE); + assertThat(normalMember.getRefreshToken()).isNull(); + } + + @Test + @DisplayName("카카오_연결_끊기가_호출된다") + void 카카오_연결_끊기가_호출된다() { + // given + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.withdrawMember(normalMember.getId()); + + // then + then(kakaoOauthService).should().unlinkAccess(normalMember); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberCommandService.withdrawMember(999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("이미_탈퇴한_회원이면_ALREADY_WITHDRAW_예외를_던진다") + void 이미_탈퇴한_회원이면_ALREADY_WITHDRAW_예외를_던진다() { + // given + Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉", 9002L); + ReflectionTestUtils.setField(withdrawnMember, "id", 2L); + + given(memberRepository.findById(withdrawnMember.getId())) + .willReturn(Optional.of(withdrawnMember)); + + // when & then + assertThatThrownBy(() -> memberCommandService.withdrawMember(withdrawnMember.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.ALREADY_WITHDRAW); + } + + @Test + @DisplayName("활성_모임의_모임장이면_MANAGER_CANNOT_LEAVE_예외를_던진다") + void 활성_모임의_모임장이면_MANAGER_CANNOT_LEAVE_예외를_던진다() { + // given + MemberParty leaderParty = MemberParty.builder() + .role(Role.party_MANAGER) + .status(MemberPartyStatus.ACTIVE) + .joinedAt(LocalDateTime.now()) + .build(); + normalMember.getMemberParties().add(leaderParty); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when & then + assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MANAGER_CANNOT_LEAVE); + } + + @Test + @DisplayName("활성_모임의_부모임장이면_SUBMANAGER_CANNOT_LEAVE_예외를_던진다") + void 활성_모임의_부모임장이면_SUBMANAGER_CANNOT_LEAVE_예외를_던진다() { + // given + MemberParty subManagerParty = MemberParty.builder() + .role(Role.party_SUBMANAGER) + .status(MemberPartyStatus.ACTIVE) + .joinedAt(LocalDateTime.now()) + .build(); + normalMember.getMemberParties().add(subManagerParty); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when & then + assertThatThrownBy(() -> memberCommandService.withdrawMember(normalMember.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.SUBMANAGER_CANNOT_LEAVE); + } + + @Test + @DisplayName("비활성_모임의_모임장이면_탈퇴가_가능하다") + void 비활성_모임의_모임장이면_탈퇴가_가능하다() { + // given: BANNED 상태의 모임이라면 탈퇴 검증을 통과해야 한다 + MemberParty bannedParty = MemberParty.builder() + .role(Role.party_MANAGER) + .status(MemberPartyStatus.BANNED) + .joinedAt(LocalDateTime.now()) + .build(); + normalMember.getMemberParties().add(bannedParty); + + given(memberRepository.findById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + + // when + memberCommandService.withdrawMember(normalMember.getId()); + + // then: 예외 없이 탈퇴 처리됨 + assertThat(normalMember.getIsActive()).isEqualTo(MemberStatus.INACTIVE); + } + } } } diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java new file mode 100644 index 000000000..b7c521268 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java @@ -0,0 +1,344 @@ +package umc.cockple.demo.domain.member.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.contest.domain.Contest; +import umc.cockple.demo.domain.contest.enums.MedalType; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.domain.MemberExercise; +import umc.cockple.demo.domain.member.domain.ProfileImg; +import umc.cockple.demo.domain.member.dto.GetAllAddressResponseDTO; +import umc.cockple.demo.domain.member.dto.GetMyProfileResponseDTO; +import umc.cockple.demo.domain.member.dto.GetNowAddressResponseDTO; +import umc.cockple.demo.domain.member.dto.GetProfileResponseDTO; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Keyword; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MemberQueryService") +class MemberQueryServiceTest { + + @InjectMocks + private MemberQueryService memberQueryService; + + @Mock private MemberRepository memberRepository; + @Mock private FileService fileService; + + private Member member; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("강와나", Gender.FEMALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + } + + @Nested + @DisplayName("getProfile") + class GetProfile { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("profileImg가_있으면_fileService로_url을_변환해서_반환한다") + void profileImg가_있으면_fileService로_url을_변환해서_반환한다() { + // given + ProfileImg profileImg = ProfileImg.builder() + .imgKey("profile/test-key.jpg") + .build(); + ReflectionTestUtils.setField(member, "profileImg", profileImg); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(fileService.getUrlFromKey("profile/test-key.jpg")) + .willReturn("https://cdn.example.com/profile/test-key.jpg"); + + // when + GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); + + // then + assertThat(response.profileImgUrl()).isEqualTo("https://cdn.example.com/profile/test-key.jpg"); + then(fileService).should().getUrlFromKey("profile/test-key.jpg"); + } + + @Test + @DisplayName("profileImg가_없으면_imgUrl이_null로_반환된다") + void profileImg가_없으면_imgUrl이_null로_반환된다() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); + + // then + assertThat(response.profileImgUrl()).isNull(); + then(fileService).should(never()).getUrlFromKey(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("금_은_동_메달_개수가_올바르게_집계된다") + void 금_은_동_메달_개수가_올바르게_집계된다() { + // given + Contest gold1 = Contest.builder().medalType(MedalType.GOLD).build(); + Contest gold2 = Contest.builder().medalType(MedalType.GOLD).build(); + Contest silver = Contest.builder().medalType(MedalType.SILVER).build(); + Contest bronze = Contest.builder().medalType(MedalType.BRONZE).build(); + + member.getContests().addAll(List.of(gold1, gold2, silver, bronze)); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); + + // then + assertThat(response.myGoldMedalCnt()).isEqualTo(2); + assertThat(response.mySilverMedalCnt()).isEqualTo(1); + assertThat(response.myBronzeMedalCnt()).isEqualTo(1); + } + + @Test + @DisplayName("참여한_모임_수가_올바르게_반환된다") + void 참여한_모임_수가_올바르게_반환된다() { + // given + member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.party_MEMBER)); + member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.party_MEMBER)); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + GetProfileResponseDTO response = memberQueryService.getProfile(member.getId()); + + // then + assertThat(response.myPartyCnt()).isEqualTo(2); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberQueryService.getProfile(999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + } + } + + + @Nested + @DisplayName("getMyProfile") + class GetMyProfile { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("대표주소_운동횟수_키워드가_포함된_내_프로필이_반환된다") + void 대표주소_운동횟수_키워드가_포함된_내_프로필이_반환된다() { + // given + MemberAddr mainAddr = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true); + member.getAddresses().add(mainAddr); + + MemberExercise exercise = MemberFixture.createMemberExercise(member, null); + member.getMemberExercises().add(exercise); + + umc.cockple.demo.domain.member.domain.MemberKeyword keyword = + umc.cockple.demo.domain.member.domain.MemberKeyword.builder() + .member(member) + .keyword(Keyword.FRIENDSHIP) + .build(); + member.getKeywords().add(keyword); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + GetMyProfileResponseDTO response = memberQueryService.getMyProfile(member.getId()); + + // then + assertThat(response.addr3()).isEqualTo("역삼동"); + assertThat(response.myExerciseCnt()).isEqualTo(1); + assertThat(response.keywords()).containsExactly(Keyword.FRIENDSHIP); + } + } + } + + + @Nested + @DisplayName("getNowAddress") + class GetNowAddress { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("대표주소를_반환한다") + void 대표주소를_반환한다() { + // given + MemberAddr mainAddr = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true); + member.getAddresses().add(mainAddr); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + GetNowAddressResponseDTO response = memberQueryService.getNowAddress(member.getId()); + + // then + assertThat(response.addr3()).isEqualTo("역삼동"); + assertThat(response.streetAddr()).isEqualTo("서울특별시 강남구 테헤란로 1"); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberQueryService.getNowAddress(999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가_없으면_MAIN_ADDRESS_NULL_예외를_던진다") + void 대표주소가_없으면_MAIN_ADDRESS_NULL_예외를_던진다() { + // given: isMain=false인 주소만 존재 + MemberAddr nonMainAddr = MemberAddrFixture.createAddr(member, "삼성동", "서울특별시 강남구 테헤란로 1", false); + member.getAddresses().add(nonMainAddr); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberQueryService.getNowAddress(member.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MAIN_ADDRESS_NULL); + } + } + } + + + @Nested + @DisplayName("getAllAddress") + class GetAllAddress { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("대표주소가_목록_첫_번째로_반환된다") + void 대표주소가_목록_첫_번째로_반환된다() { + // given + MemberAddr nonMain = MemberAddrFixture.createAddr(member, "삼성동", "서울특별시 강남구 테헤란로 1", false); + MemberAddr main = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true); + ReflectionTestUtils.setField(nonMain, "id", 1L); + ReflectionTestUtils.setField(main, "id", 2L); + + member.getAddresses().addAll(List.of(nonMain, main)); // 비대표, 대표 순서로 추가 + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + List result = memberQueryService.getAllAddress(member.getId()); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).isMainAddr()).isTrue(); + assertThat(result.get(0).addr3()).isEqualTo("역삼동"); + } + + @Test + @DisplayName("대표주소_제외_나머지는_id_오름차순으로_정렬된다") + void 대표주소_제외_나머지는_id_오름차순으로_정렬된다() { + // given + MemberAddr main = MemberAddrFixture.createAddr(member, "역삼동", "서울특별시 강남구 테헤란로 1", true); + MemberAddr non1 = MemberAddrFixture.createAddr(member, "삼성동", "서울특별시 강남구 테헤란로 1", false); + MemberAddr non2 = MemberAddrFixture.createAddr(member, "청담동", "서울특별시 강남구 테헤란로 1", false); + ReflectionTestUtils.setField(main, "id", 1L); + ReflectionTestUtils.setField(non1, "id", 2L); + ReflectionTestUtils.setField(non2, "id", 3L); + + member.getAddresses().addAll(List.of(non2, non1, main)); // 섞인 순서로 추가 + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + List result = memberQueryService.getAllAddress(member.getId()); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(0).addr3()).isEqualTo("역삼동"); // 대표주소 먼저 + assertThat(result.get(1).addr3()).isEqualTo("삼성동"); // id=2 + assertThat(result.get(2).addr3()).isEqualTo("청담동"); // id=3 + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_예외를_던진다() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberQueryService.getAllAddress(999L)) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("주소가_없으면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다") + void 주소가_없으면_MEMBER_ADDRESS_MINIMUM_REQUIRED_예외를_던진다() { + // given: 주소가 비어있는 경우 + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberQueryService.getAllAddress(member.getId())) + .isInstanceOf(MemberException.class) + .hasFieldOrPropertyWithValue("code", MemberErrorCode.MEMBER_ADDRESS_MINIMUM_REQUIRED); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java new file mode 100644 index 000000000..6fb42611d --- /dev/null +++ b/src/test/java/umc/cockple/demo/support/fixture/MemberAddrFixture.java @@ -0,0 +1,115 @@ +package umc.cockple.demo.support.fixture; + +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; + +public class MemberAddrFixture { + + /** + * 임의 테스트 주소 + * 대표주소 + * - 서울특별시 강남구 역삼동 테헤란로 123 / ㅁㅁ빌딩 (37.5, 127.0) + */ + public static MemberAddr createMainAddr(Member member) { + return MemberAddr.builder() + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("테헤란로 123") + .buildingName("ㅁㅁ빌딩") + .latitude(37.5) + .longitude(127.0) + .isMain(true) + .member(member) + .build(); + } + + /** + * 임의 테스트 주소 + * 비대표주소 + * - 서울특별시 서초구 서초동 서초대로 456 / ㅇㅇ빌딩 (37.4, 127.1) + */ + public static MemberAddr createSubAddr(Member member) { + return MemberAddr.builder() + .addr1("서울특별시") + .addr2("서초구") + .addr3("서초동") + .streetAddr("서초대로 456") + .buildingName("ㅇㅇ빌딩") + .latitude(37.4) + .longitude(127.1) + .isMain(false) + .member(member) + .build(); + } + + /** + * 커스텀주소 + */ + public static MemberAddr createAddr(Member member, String addr3, String streetAddr, boolean isMain) { + return MemberAddr.builder() + .addr1("경기도") + .addr2("안산시") + .addr3(addr3) + .streetAddr(streetAddr) + .buildingName("빌딩" + addr3) + .latitude(37.5) + .longitude(127.0) + .isMain(isMain) + .member(member) + .build(); + } + + /** + * 서울특별시 강남구 역삼동 (AddMemberNewAddr requestDto 기본값과 동일 - 중복 검증 등에 활용) + * - 서울특별시 강남구 역삼동 테헤란로 123 / 테스트빌딩 (37.5, 127.0) + */ + public static MemberAddr createSeoulAddr(Member member, boolean isMain) { + return MemberAddr.builder() + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("테헤란로 123") + .buildingName("테스트빌딩") + .latitude(37.5) + .longitude(127.0) + .isMain(isMain) + .member(member) + .build(); + } + + /** + * 부산광역시 해운대구 좌동 (대표주소 해제 등 기존 주소 대체용) + * - 부산광역시 해운대구 좌동 해운대로 123 / 해운대빌딩 (35.1, 129.1) + */ + public static MemberAddr createBusanAddr(Member member, boolean isMain) { + return MemberAddr.builder() + .addr1("부산광역시") + .addr2("해운대구") + .addr3("좌동") + .streetAddr("해운대로 123") + .buildingName("해운대빌딩") + .latitude(35.1) + .longitude(129.1) + .isMain(isMain) + .member(member) + .build(); + } + + /** + * 주소 5개 초과 테스트용 - index로 구분되는 고유 주소 생성 + */ + public static MemberAddr createAddrWithIndex(Member member, int index, boolean isMain) { + return MemberAddr.builder() + .addr1("서울특별시") + .addr2("구" + index) + .addr3("동" + index) + .streetAddr("도로" + index) + .buildingName("빌딩" + index) + .latitude(37.5 + index) + .longitude(127.0 + index) + .isMain(isMain) + .member(member) + .build(); + } +} From 73d71d160030fdbb1df46487e9101ff3ee0149c5 Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:17:31 +0900 Subject: [PATCH 08/20] =?UTF-8?q?[test/#538]=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD/=EC=B7=A8=EC=86=8C,=20=EA=B2=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B4=88=EB=8C=80/=EC=B7=A8=EC=86=8C=20API=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1=20(#539)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 기존에 ExerciseParticipationService에서 Builder 대신 Fixture를 사용하도록 하여 가독성 향상 * test: 나이 조건 테스트를 위해 관련 MemberFixture 추가 * test: 운동 신청 API 단위 테스트 및 통합 테스트 코드 작성 * test: ExerciseCommandService에서 Builder를 쓰던 걸 Fixture를 사용하도록 변경 * test: 운동 신청 API에서 파사드 패턴의 CommandService의 단위 테스트 구현 * test: 운동 신청 취소 API의 통합 테스트 및 단위 테스트 코드 작성 * refactor: 운동 신청, 운동 신청 취소, 게스트 초대, 게스트 초대 취소 API를 ResponseEntity로 응답을 감싸 status도 정상적으로 반환할 수 있도록 변경 * chore: 불필요한 의존성 제거 및 줄바꿈 변경 * test: 게스트 초대 API 통합 테스트 및 CommandService 단위 테스트 코드, GusetService 단위 테스트 코드 작성 * test: 게스트 초대 취소 API 통합 테스트, CommandService 단위 테스트, GuestService 단위 테스트 코드 작성 --- .../controller/ExerciseController.java | 16 +- .../internal/ExerciseGuestService.java | 5 - .../integration/ExerciseIntegrationTest.java | 483 +++++++++++++++++- .../service/ExerciseCommandServiceTest.java | 346 ++++++++++++- .../service/ExerciseGuestServiceTest.java | 249 +++++++++ .../ExerciseParticipationServiceTest.java | 237 ++++++++- .../demo/support/fixture/ExerciseFixture.java | 13 + .../demo/support/fixture/MemberFixture.java | 13 + 8 files changed, 1304 insertions(+), 58 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java index 4bbbac6aa..2ef26b025 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/controller/ExerciseController.java @@ -97,7 +97,7 @@ public ResponseEntity> updateExercise( @ApiResponse(responseCode = "200", description = "운동 신청 성공") @ApiResponse(responseCode = "400", description = "입력값 오류 또는 비즈니스 룰 위반") @ApiResponse(responseCode = "403", description = "권한 없음, 급수 위반") - public BaseResponse joinExercise( + public ResponseEntity> joinExercise( @PathVariable Long exerciseId ) { Long memberId = SecurityUtil.getCurrentMemberId(); @@ -105,7 +105,7 @@ public BaseResponse joinExercise( ExerciseJoinDTO.Response response = exerciseCommandService.joinExercise( exerciseId, memberId); - return BaseResponse.success(CommonSuccessCode.CREATED, response); + return BaseResponse.of(CommonSuccessCode.CREATED, response); } @DeleteMapping("/exercises/{exerciseId}/participants/my") @@ -114,7 +114,7 @@ public BaseResponse joinExercise( @ApiResponse(responseCode = "200", description = "운동 참여 취소 성공") @ApiResponse(responseCode = "400", description = "취소할 수 없는 상태 (이미 시작됨, 참여하지 않음 등)") @ApiResponse(responseCode = "404", description = "운동 또는 참여 기록을 찾을 수 없음") - public BaseResponse cancelParticipation( + public ResponseEntity> cancelParticipation( @PathVariable Long exerciseId ) { Long memberId = SecurityUtil.getCurrentMemberId(); @@ -122,7 +122,7 @@ public BaseResponse cancelParticipation( ExerciseCancelDTO.Response response = exerciseCommandService.cancelParticipation( exerciseId, memberId); - return BaseResponse.success(CommonSuccessCode.OK, response); + return BaseResponse.of(CommonSuccessCode.OK, response); } @DeleteMapping("/exercises/{exerciseId}/participants/{participantId}") @@ -151,7 +151,7 @@ public ResponseEntity> cancelParticipat @ApiResponse(responseCode = "201", description = "게스트 초대 성공") @ApiResponse(responseCode = "400", description = "입력값 오류 또는 비즈니스 룰 위반") @ApiResponse(responseCode = "404", description = "운동을 찾을 수 없음") - public BaseResponse inviteGuest( + public ResponseEntity> inviteGuest( @PathVariable Long exerciseId, @Valid @RequestBody ExerciseGuestInviteDTO.Request request ) { @@ -160,7 +160,7 @@ public BaseResponse inviteGuest( ExerciseGuestInviteDTO.Response response = exerciseCommandService.inviteGuest( exerciseId, inviterId, request); - return BaseResponse.success(CommonSuccessCode.CREATED, response); + return BaseResponse.of(CommonSuccessCode.CREATED, response); } @DeleteMapping("/exercises/{exerciseId}/guests/{guestId}") @@ -170,7 +170,7 @@ public BaseResponse inviteGuest( @ApiResponse(responseCode = "400", description = "취소할 수 없는 상태 (이미 시작됨)") @ApiResponse(responseCode = "403", description = "본인이 초대한 게스트가 아닌 경우 취소할 수 없음") @ApiResponse(responseCode = "404", description = "운동 또는 참여 기록을 찾을 수 없음") - public BaseResponse cancelGuestInvitation( + public ResponseEntity> cancelGuestInvitation( @PathVariable Long exerciseId, @PathVariable Long guestId ) { @@ -179,7 +179,7 @@ public BaseResponse cancelGuestInvitation( ExerciseCancelDTO.Response response = exerciseCommandService.cancelGuestInvitation( exerciseId, guestId, memberId); - return BaseResponse.success(CommonSuccessCode.OK, response); + return BaseResponse.of(CommonSuccessCode.OK, response); } @GetMapping("/exercises/{exerciseId}") diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java index 16a6e9baf..268a58a73 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/command/internal/ExerciseGuestService.java @@ -13,7 +13,6 @@ import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.exercise.service.ExerciseValidator; import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.repository.MemberRepository; @Service @Transactional @@ -22,7 +21,6 @@ public class ExerciseGuestService { private final ExerciseRepository exerciseRepository; - private final MemberRepository memberRepository; private final GuestRepository guestRepository; private final ExerciseValidator exerciseValidator; @@ -40,7 +38,6 @@ public ExerciseGuestInviteDTO.Response inviteGuest(Exercise exercise, Member inv Guest savedGuest = guestRepository.save(guest); log.info("게스트 초대 완료 - guestId: {}", savedGuest.getId()); - return exerciseConverter.toGuestInviteResponse(savedGuest, exercise); } @@ -48,13 +45,11 @@ public ExerciseCancelDTO.Response cancelGuestInvitation(Exercise exercise, Guest exerciseValidator.validateCancelGuestInvitation(exercise, guest, member); exercise.removeGuest(guest); - guestRepository.delete(guest); exerciseRepository.save(exercise); log.info("게스트 초대 취소 완료 - exerciseId: {}, guestId: {}, memberId: {}", exercise.getId(), guest.getId(), member.getId()); - return exerciseConverter.toCancelResponse(exercise, guest); } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java index b179a0f73..43d522a4d 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java @@ -8,6 +8,7 @@ import umc.cockple.demo.domain.exercise.domain.Guest; import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -34,6 +35,7 @@ import umc.cockple.demo.support.fixture.GuestFixture; import java.time.LocalDate; +import java.time.LocalTime; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -61,9 +63,9 @@ class ExerciseIntegrationTest extends IntegrationTestBase { @BeforeEach void setUp() { - manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L)); - subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L)); - normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L)); + manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L, LocalDate.of(2000, 1, 1))); + subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L, LocalDate.of(2000, 1, 1))); + normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L, LocalDate.of(2000, 1, 1))); PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); @@ -473,6 +475,239 @@ void pastDate() throws Exception { } } + + @Nested + @DisplayName("POST /api/exercises/{exerciseId}/participants - 운동 신청") + class JoinExercise { + + private Exercise exercise; + + @BeforeEach + void setUp() { + party.addLevel(Gender.MALE, Level.A); + party.addLevel(Gender.MALE, Level.B); + party.addLevel(Gender.MALE, Level.C); + party.addLevel(Gender.FEMALE, Level.A); + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.FEMALE, Level.C); + partyRepository.save(party); + + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("201 - 파티 멤버가 운동 신청하면 참여 정보를 반환한다") + void partyMember_joinExercise() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.participantId").isNumber()) + .andExpect(jsonPath("$.data.joinedAt").isString()) + .andExpect(jsonPath("$.data.currentParticipants").value(1)); + } + + @Test + @DisplayName("201 - 파티 외부 멤버가 outsideGuestAccept=true 운동 신청하면 성공한다") + void outsideMember_joinExercise() throws Exception { + Exercise outsideAcceptExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, true)); + + Member outsideMember = memberRepository.save( + MemberFixture.createMember("외부멤버", Gender.FEMALE, Level.C, 2001L, LocalDate.of(2000, 1, 1))); + + SecurityContextHelper.setAuthentication(outsideMember.getId(), outsideMember.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", outsideAcceptExercise.getId())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.participantId").isNumber()) + .andExpect(jsonPath("$.data.currentParticipants").value(1)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - 존재하지 않는 멤버면 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다") + void alreadyStarted() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Exercise startedExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false)); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", startedExercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_PARTICIPATION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_PARTICIPATION.getMessage())); + } + + @Test + @DisplayName("400 - 이미 참여 신청한 운동이면 에러를 반환한다") + void alreadyJoined() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + memberExerciseRepository.save( + MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.ALREADY_JOINED_EXERCISE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.ALREADY_JOINED_EXERCISE.getMessage())); + } + + @Test + @DisplayName("403 - 파티 멤버가 아닌데 외부 참여 불가 운동이면 에러를 반환한다") + void notPartyMember_outsideNotAccepted() throws Exception { + Member outsideMember = memberRepository.save( + MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L, LocalDate.of(2000, 1, 1))); + + SecurityContextHelper.setAuthentication(outsideMember.getId(), outsideMember.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.NOT_PARTY_MEMBER.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.NOT_PARTY_MEMBER.getMessage())); + } + + @Test + @DisplayName("403 - 나이 조건 불일치면 에러를 반환한다") + void ageNotAllowed() throws Exception { + Member youngMember = memberRepository.save( + MemberFixture.createMember("어린회원", Gender.MALE, Level.B, 4001L, LocalDate.of(2010, 1, 1))); + memberPartyRepository.save(MemberFixture.createMemberParty(party, youngMember, Role.party_MEMBER)); + + SecurityContextHelper.setAuthentication(youngMember.getId(), youngMember.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/participants", exercise.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_AGE_NOT_ALLOWED.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_AGE_NOT_ALLOWED.getMessage())); + } + + } + } + + @Nested + @DisplayName("DELETE /api/exercises/{exerciseId}/participants/my - 운동 참여 취소") + class CancelParticipation { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 참여자가 본인 참여를 취소하면 memberName을 반환한다") + void cancelParticipation_success() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + memberExerciseRepository.save( + MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.currentParticipants").value(0)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다") + void alreadyStarted() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Exercise startedExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false)); + + memberExerciseRepository.save( + MemberFixture.createMemberExercise(normalMember, startedExercise)); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", startedExercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getMessage())); + } + + @Test + @DisplayName("404 - 참여 기록이 없으면 에러를 반환한다") + void memberExerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/participants/my", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_EXERCISE_NOT_FOUND.getMessage())); + } + } + } + @Nested @DisplayName("DELETE /api/exercises/{exerciseId}/participants/{participantId} - 특정 참여자 운동 취소") class CancelParticipationByManager { @@ -619,6 +854,248 @@ void alreadyStarted() throws Exception { } } + @Nested + @DisplayName("POST /api/exercises/{exerciseId}/guests - 게스트 초대") + class InviteGuest { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("201 - 파티 멤버가 게스트를 초대하면 guestId를 반환한다") + void partyMember_inviteGuest() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/guests", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.guestId").isNumber()) + .andExpect(jsonPath("$.data.invitedAt").isString()) + .andExpect(jsonPath("$.data.currentParticipants").value(1)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/guests", 999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/guests", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다") + void alreadyStarted() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Exercise startedExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false)); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/guests", startedExercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_INVITATION.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_INVITATION.getMessage())); + } + + @Test + @DisplayName("403 - 파티 멤버가 아닌 사용자가 초대하면 에러를 반환한다") + void notPartyMember() throws Exception { + Member outsideMember = memberRepository.save( + MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L, LocalDate.of(2000, 1, 1))); + + SecurityContextHelper.setAuthentication(outsideMember.getId(), outsideMember.getNickname()); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/guests", exercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.NOT_PARTY_MEMBER_FOR_GUEST_INVITE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.NOT_PARTY_MEMBER_FOR_GUEST_INVITE.getMessage())); + } + + @Test + @DisplayName("403 - 게스트 초대 정책 비허용이면 에러를 반환한다") + void guestPolicyNotAllowed() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Exercise noGuestExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), false, false)); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + mockMvc.perform(post("/api/exercises/{exerciseId}/guests", noGuestExercise.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.GUEST_INVITATION_NOT_ALLOWED.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.GUEST_INVITATION_NOT_ALLOWED.getMessage())); + } + } + } + + @Nested + @DisplayName("DELETE /api/exercises/{exerciseId}/guests/{guestId} - 게스트 초대 취소") + class CancelGuestInvitation { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 초대자가 본인 게스트를 취소하면 memberName을 반환한다") + void cancelGuestInvitation_success() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, normalMember.getId())); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}", + exercise.getId(), guest.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberName").value("게스트")) + .andExpect(jsonPath("$.data.currentParticipants").value(0)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, normalMember.getId())); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}", + 999L, guest.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("404 - SecurityContext의 멤버가 DB에 없으면 에러를 반환한다") + void memberNotFound() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, normalMember.getId())); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}", + exercise.getId(), guest.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 시작된 운동이면 에러를 반환한다") + void alreadyStarted() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Exercise startedExercise = exerciseRepository.save( + ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false)); + + Guest guest = guestRepository.save(GuestFixture.createGuest(startedExercise, normalMember.getId())); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}", + startedExercise.getId(), guest.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL.getMessage())); + } + + @Test + @DisplayName("404 - 존재하지 않는 게스트면 에러를 반환한다") + void guestNotFound() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}", + exercise.getId(), 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.GUEST_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.GUEST_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("403 - 본인이 초대하지 않은 게스트면 에러를 반환한다") + void guestNotInvitedByMember() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + Guest guest = guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/guests/{guestId}", + exercise.getId(), guest.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.GUEST_NOT_INVITED_BY_MEMBER.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.GUEST_NOT_INVITED_BY_MEMBER.getMessage())); + } + } + } + @Nested @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회") class GetExerciseDetail { diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java index 506d55746..6d93406a6 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseCommandServiceTest.java @@ -10,9 +10,12 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseCreateDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseDeleteDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseJoinDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseUpdateDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; @@ -28,6 +31,8 @@ import umc.cockple.demo.domain.party.repository.PartyRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -156,14 +161,8 @@ class DeleteExercise { @BeforeEach void setUp() { - exercise = Exercise.builder() - .date(LocalDate.of(2099, 12, 31)) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .build(); + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); ReflectionTestUtils.setField(exercise, "id", 100L); } @@ -233,14 +232,8 @@ class UpdateExercise { @BeforeEach void setUp() { - exercise = Exercise.builder() - .date(LocalDate.of(2099, 12, 31)) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .build(); + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); ReflectionTestUtils.setField(exercise, "id", 100L); request = new ExerciseUpdateDTO.Request( @@ -313,6 +306,150 @@ void memberNotFound_throwsException() { } } + @Nested + @DisplayName("joinExercise") + class JoinExercise { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise(WithPartyLevels), Member 조회 후 ExerciseParticipationService에 위임한다") + void delegatesToParticipationService() { + // given + ExerciseJoinDTO.Response expectedResponse = ExerciseJoinDTO.Response.builder() + .participantId(50L) + .currentParticipants(1) + .build(); + + given(exerciseRepository.findByIdWithPartyLevels(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseParticipationService.joinExercise(exercise, manager)).willReturn(expectedResponse); + + // when + ExerciseJoinDTO.Response response = exerciseCommandService.joinExercise( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participantId()).isEqualTo(50L); + assertThat(response.currentParticipants()).isEqualTo(1); + then(exerciseParticipationService).should().joinExercise(exercise, manager); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + given(exerciseRepository.findByIdWithPartyLevels(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.joinExercise(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(exerciseRepository.findByIdWithPartyLevels(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.joinExercise(exercise.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("cancelParticipation") + class CancelParticipation { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise, Member 조회 후 ExerciseParticipationService에 위임한다") + void delegatesToParticipationService() { + // given + ExerciseCancelDTO.Response expectedResponse = ExerciseCancelDTO.Response.builder() + .memberName("모임장") + .currentParticipants(0) + .build(); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseParticipationService.cancelParticipation(exercise, manager)).willReturn(expectedResponse); + + // when + ExerciseCancelDTO.Response response = exerciseCommandService.cancelParticipation( + exercise.getId(), manager.getId()); + + // then + assertThat(response.memberName()).isEqualTo("모임장"); + assertThat(response.currentParticipants()).isEqualTo(0); + then(exerciseParticipationService).should().cancelParticipation(exercise, manager); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelParticipation(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelParticipation(exercise.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } + @Nested @DisplayName("cancelParticipationByManager") class CancelParticipationByManager { @@ -321,14 +458,8 @@ class CancelParticipationByManager { @BeforeEach void setUp() { - exercise = Exercise.builder() - .date(LocalDate.of(2099, 12, 31)) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .build(); + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); ReflectionTestUtils.setField(exercise, "id", 100L); } @@ -396,4 +527,171 @@ void managerNotFound_throwsException() { } } } + + @Nested + @DisplayName("inviteGuest") + class InviteGuest { + + private Exercise exercise; + private ExerciseGuestInviteDTO.Request request; + + @BeforeEach + void setUp() { + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(exercise, "id", 100L); + + request = new ExerciseGuestInviteDTO.Request("테스트게스트", "남성", "B조"); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise, Member 조회 후 ExerciseGuestService에 위임한다") + void delegatesToGuestService() { + // given + ExerciseGuestInviteDTO.Response expectedResponse = ExerciseGuestInviteDTO.Response.builder() + .guestId(200L) + .currentParticipants(1) + .build(); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(exerciseGuestService.inviteGuest(exercise, manager, request)).willReturn(expectedResponse); + + // when + ExerciseGuestInviteDTO.Response response = exerciseCommandService.inviteGuest( + exercise.getId(), manager.getId(), request); + + // then + assertThat(response.guestId()).isEqualTo(200L); + assertThat(response.currentParticipants()).isEqualTo(1); + then(exerciseGuestService).should().inviteGuest(exercise, manager, request); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.inviteGuest(999L, manager.getId(), request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.inviteGuest(exercise.getId(), 999L, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("cancelGuestInvitation") + class CancelGuestInvitation { + + private Exercise exercise; + private Guest guest; + + @BeforeEach + void setUp() { + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(exercise, "id", 100L); + + guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 60L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("Exercise, Member, Guest 조회 후 ExerciseGuestService에 위임한다") + void delegatesToGuestService() { + // given + ExerciseCancelDTO.Response expectedResponse = ExerciseCancelDTO.Response.builder() + .memberName("게스트") + .currentParticipants(0) + .build(); + + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(guestRepository.findById(guest.getId())).willReturn(Optional.of(guest)); + given(exerciseGuestService.cancelGuestInvitation(exercise, guest, manager)) + .willReturn(expectedResponse); + + // when + ExerciseCancelDTO.Response response = exerciseCommandService.cancelGuestInvitation( + exercise.getId(), guest.getId(), manager.getId()); + + // then + assertThat(response.memberName()).isEqualTo("게스트"); + assertThat(response.currentParticipants()).isEqualTo(0); + then(exerciseGuestService).should().cancelGuestInvitation(exercise, guest, manager); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsException() { + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelGuestInvitation(999L, guest.getId(), manager.getId())) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 멤버면 ExerciseException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsException() { + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelGuestInvitation(exercise.getId(), guest.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 게스트면 ExerciseException(GUEST_NOT_FOUND)을 던진다") + void guestNotFound_throwsException() { + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(guestRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseCommandService.cancelGuestInvitation(exercise.getId(), 999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.GUEST_NOT_FOUND)); + } + } + } } \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java new file mode 100644 index 000000000..23034e613 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseGuestServiceTest.java @@ -0,0 +1,249 @@ +package umc.cockple.demo.domain.exercise.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseGuestInviteDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.exercise.service.command.internal.ExerciseGuestService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseGuestService") +class ExerciseGuestServiceTest { + + @Mock private ExerciseRepository exerciseRepository; + @Mock private GuestRepository guestRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private FileService fileService; + + private ExerciseGuestService exerciseGuestService; + + private Member manager; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseValidator exerciseValidator = new ExerciseValidator(memberPartyRepository, memberExerciseRepository); + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + exerciseGuestService = new ExerciseGuestService( + exerciseRepository, guestRepository, exerciseValidator, exerciseConverter); + + manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(manager, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("inviteGuest") + class InviteGuest { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("파티 멤버가 게스트를 초대하면 Response를 반환한다") + void partyMember_inviteGuest_success() { + // given + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + given(memberPartyRepository.existsByPartyAndMember(party, manager)).willReturn(true); + given(guestRepository.save(any(Guest.class))) + .willAnswer(invocation -> { + Guest g = invocation.getArgument(0); + ReflectionTestUtils.setField(g, "id", 200L); + return g; + }); + + // when + ExerciseGuestInviteDTO.Response response = exerciseGuestService.inviteGuest( + exercise, manager, request); + + // then + assertThat(response.guestId()).isEqualTo(200L); + assertThat(response.currentParticipants()).isNotNull(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_INVITATION)을 던진다") + void alreadyStarted_throwsException() { + Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(startedExercise, "id", 200L); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + assertThatThrownBy(() -> + exerciseGuestService.inviteGuest(startedExercise, manager, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_INVITATION)); + } + + @Test + @DisplayName("파티 멤버가 아닌 사람이 초대하면 ExerciseException(NOT_PARTY_MEMBER_FOR_GUEST_INVITE)을 던진다") + void notPartyMember_throwsException() { + Member outsider = MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L); + ReflectionTestUtils.setField(outsider, "id", 3L); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + given(memberPartyRepository.existsByPartyAndMember(party, outsider)).willReturn(false); + + assertThatThrownBy(() -> + exerciseGuestService.inviteGuest(exercise, outsider, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.NOT_PARTY_MEMBER_FOR_GUEST_INVITE)); + } + + @Test + @DisplayName("게스트 초대 정책 비허용이면 ExerciseException(GUEST_INVITATION_NOT_ALLOWED)을 던진다") + void guestPolicyNotAllowed_throwsException() { + Exercise noGuestExercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), false, false); + ReflectionTestUtils.setField(noGuestExercise, "id", 201L); + + ExerciseGuestInviteDTO.Request request = new ExerciseGuestInviteDTO.Request( + "테스트게스트", "남성", "B조"); + + given(memberPartyRepository.existsByPartyAndMember(party, manager)).willReturn(true); + + assertThatThrownBy(() -> + exerciseGuestService.inviteGuest(noGuestExercise, manager, request)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.GUEST_INVITATION_NOT_ALLOWED)); + } + } + } + + @Nested + @DisplayName("cancelGuestInvitation") + class CancelGuestInvitation { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("초대자가 본인 게스트를 취소하면 Response를 반환한다") + void cancelGuestInvitation_success() { + // given + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 60L); + + // when + ExerciseCancelDTO.Response response = exerciseGuestService + .cancelGuestInvitation(exercise, guest, manager); + + // then + assertThat(response.memberName()).isEqualTo("게스트"); + assertThat(response.currentParticipants()).isNotNull(); + then(guestRepository).should().delete(guest); + then(exerciseRepository).should().save(exercise); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다") + void alreadyStarted_throwsException() { + Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(startedExercise, "id", 200L); + + Guest guest = GuestFixture.createGuest(startedExercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 60L); + + assertThatThrownBy(() -> + exerciseGuestService.cancelGuestInvitation(startedExercise, guest, manager)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL)); + } + + @Test + @DisplayName("게스트가 해당 운동에 속하지 않으면 ExerciseException(GUEST_IS_NOT_PARTICIPATED_IN_EXERCISE)을 던진다") + void guestNotInExercise_throwsException() { + Exercise otherExercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(otherExercise, "id", 201L); + + Guest guest = GuestFixture.createGuest(otherExercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 60L); + + assertThatThrownBy(() -> + exerciseGuestService.cancelGuestInvitation(exercise, guest, manager)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.GUEST_IS_NOT_PARTICIPATED_IN_EXERCISE)); + } + + @Test + @DisplayName("본인이 초대하지 않은 게스트면 ExerciseException(GUEST_NOT_INVITED_BY_MEMBER)을 던진다") + void guestNotInvitedByMember_throwsException() { + Guest guest = GuestFixture.createGuest(exercise, 999L); + ReflectionTestUtils.setField(guest, "id", 60L); + + assertThatThrownBy(() -> + exerciseGuestService.cancelGuestInvitation(exercise, guest, manager)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.GUEST_NOT_INVITED_BY_MEMBER)); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java index 9050caf03..68ce83bee 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java @@ -12,6 +12,7 @@ import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.domain.Guest; import umc.cockple.demo.domain.exercise.dto.ExerciseCancelDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseJoinDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -27,6 +28,7 @@ import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.GuestFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -37,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -72,16 +75,221 @@ void setUp() { PartyFixture.createPartyAddr("서울특별시", "강남구")); ReflectionTestUtils.setField(party, "id", 10L); - exercise = Exercise.builder() - .date(LocalDate.of(2099, 12, 31)) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .build(); + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, false); ReflectionTestUtils.setField(exercise, "id", 100L); - exercise.setParty(party); + } + + @Nested + @DisplayName("joinExercise") + class JoinExercise { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("파티 멤버가 운동 신청하면 Response를 반환한다") + void partyMember_joinExercise_success() { + // given + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L, LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(participant, "id", 2L); + + given(memberExerciseRepository.existsByExerciseAndMember(exercise, participant)).willReturn(false); + given(memberPartyRepository.existsByPartyAndMember(party, participant)).willReturn(true); + given(memberExerciseRepository.save(any(MemberExercise.class))) + .willAnswer(invocation -> { + MemberExercise me = invocation.getArgument(0); + ReflectionTestUtils.setField(me, "id", 50L); + return me; + }); + + // when + ExerciseJoinDTO.Response response = exerciseParticipationService.joinExercise(exercise, participant); + + // then + assertThat(response.participantId()).isEqualTo(50L); + assertThat(response.currentParticipants()).isNotNull(); + } + + @Test + @DisplayName("외부 참여자가 outsideGuestAccept=true 운동 신청하면 Response를 반환한다") + void outsideMember_joinExercise_success() { + // given + Exercise outsideAcceptExercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31), + LocalTime.of(12, 0), true, true); + ReflectionTestUtils.setField(outsideAcceptExercise, "id", 101L); + + Member outsideMember = MemberFixture.createMember("외부참여자", Gender.FEMALE, Level.C, 3001L, LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(outsideMember, "id", 3L); + + given(memberExerciseRepository.existsByExerciseAndMember(outsideAcceptExercise, outsideMember)).willReturn(false); + given(memberPartyRepository.existsByPartyAndMember(party, outsideMember)).willReturn(false); + given(memberExerciseRepository.save(any(MemberExercise.class))) + .willAnswer(invocation -> { + MemberExercise me = invocation.getArgument(0); + ReflectionTestUtils.setField(me, "id", 51L); + return me; + }); + + // when + ExerciseJoinDTO.Response response = exerciseParticipationService.joinExercise(outsideAcceptExercise, outsideMember); + + // then + assertThat(response.participantId()).isEqualTo(51L); + assertThat(response.currentParticipants()).isNotNull(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_PARTICIPATION)을 던진다") + void alreadyStarted_throwsException() { + Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(startedExercise, "id", 200L); + + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 2L); + + assertThatThrownBy(() -> + exerciseParticipationService.joinExercise(startedExercise, participant)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_PARTICIPATION)); + } + + @Test + @DisplayName("이미 참여 신청한 운동이면 ExerciseException(ALREADY_JOINED_EXERCISE)을 던진다") + void alreadyJoined_throwsException() { + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 2L); + + given(memberExerciseRepository.existsByExerciseAndMember(exercise, participant)).willReturn(true); + + assertThatThrownBy(() -> + exerciseParticipationService.joinExercise(exercise, participant)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.ALREADY_JOINED_EXERCISE)); + } + + @Test + @DisplayName("파티 멤버가 아닌데 외부 참여 불가 운동이면 ExerciseException(NOT_PARTY_MEMBER)을 던진다") + void notPartyMember_outsideNotAccepted_throwsException() { + Member outsideMember = MemberFixture.createMember("외부인", Gender.MALE, Level.B, 3001L); + ReflectionTestUtils.setField(outsideMember, "id", 3L); + + given(memberExerciseRepository.existsByExerciseAndMember(exercise, outsideMember)).willReturn(false); + given(memberPartyRepository.existsByPartyAndMember(party, outsideMember)).willReturn(false); + + assertThatThrownBy(() -> + exerciseParticipationService.joinExercise(exercise, outsideMember)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.NOT_PARTY_MEMBER)); + } + + @Test + @DisplayName("나이 조건 불일치면 ExerciseException(MEMBER_AGE_NOT_ALLOWED)을 던진다") + void ageNotAllowed_throwsException() { + Member youngMember = MemberFixture.createMember("어린회원", Gender.MALE, Level.B, 4001L, LocalDate.of(2010, 1, 1)); + ReflectionTestUtils.setField(youngMember, "id", 4L); + + given(memberExerciseRepository.existsByExerciseAndMember(exercise, youngMember)).willReturn(false); + given(memberPartyRepository.existsByPartyAndMember(party, youngMember)).willReturn(true); + + assertThatThrownBy(() -> + exerciseParticipationService.joinExercise(exercise, youngMember)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_AGE_NOT_ALLOWED)); + } + } + } + + + + + @Nested + @DisplayName("cancelParticipation") + class CancelParticipation { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("참여자가 본인 참여를 취소하면 Response를 반환한다") + void cancelParticipation_success() { + // given + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 2L); + + MemberExercise memberExercise = MemberFixture.createMemberExercise(participant, exercise); + ReflectionTestUtils.setField(memberExercise, "id", 50L); + + given(memberExerciseRepository.findByExerciseAndMember(exercise, participant)) + .willReturn(Optional.of(memberExercise)); + + // when + ExerciseCancelDTO.Response response = exerciseParticipationService + .cancelParticipation(exercise, participant); + + // then + assertThat(response.memberName()).isEqualTo(participant.getMemberName()); + assertThat(response.currentParticipants()).isNotNull(); + then(memberExerciseRepository).should().delete(memberExercise); + then(exerciseRepository).should().save(exercise); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다") + void alreadyStarted_throwsException() { + Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false); + ReflectionTestUtils.setField(startedExercise, "id", 200L); + + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 2L); + + MemberExercise memberExercise = MemberFixture.createMemberExercise(participant, startedExercise); + ReflectionTestUtils.setField(memberExercise, "id", 50L); + + given(memberExerciseRepository.findByExerciseAndMember(startedExercise, participant)) + .willReturn(Optional.of(memberExercise)); + + assertThatThrownBy(() -> + exerciseParticipationService.cancelParticipation(startedExercise, participant)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_ALREADY_STARTED_CANCEL)); + } + + @Test + @DisplayName("참여 기록이 없으면 ExerciseException(MEMBER_EXERCISE_NOT_FOUND)을 던진다") + void memberExerciseNotFound_throwsException() { + Member participant = MemberFixture.createMember("참여자", Gender.MALE, Level.B, 2001L); + ReflectionTestUtils.setField(participant, "id", 2L); + + given(memberExerciseRepository.findByExerciseAndMember(exercise, participant)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + exerciseParticipationService.cancelParticipation(exercise, participant)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_EXERCISE_NOT_FOUND)); + } + } } @Nested @@ -199,16 +407,9 @@ void normalMember_throwsException() { @Test @DisplayName("이미 시작된 운동이면 ExerciseException(EXERCISE_ALREADY_STARTED_CANCEL)을 던진다") void alreadyStarted_throwsException() { - Exercise startedExercise = Exercise.builder() - .date(LocalDate.of(2000, 1, 1)) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .build(); + Exercise startedExercise = ExerciseFixture.createExercise(party, LocalDate.of(2000, 1, 1), + LocalTime.of(12, 0), true, false); ReflectionTestUtils.setField(startedExercise, "id", 200L); - startedExercise.setParty(party); ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java index e82fd338f..aca0f3fde 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java @@ -20,6 +20,19 @@ public static Exercise createExercise(Party party, LocalDate date) { .build(); } + public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, + boolean partyGuestAccept, boolean outsideGuestAccept) { + return Exercise.builder() + .party(party) + .date(date) + .startTime(LocalTime.of(10, 0)) + .endTime(endTime) + .maxCapacity(10) + .partyGuestAccept(partyGuestAccept) + .outsideGuestAccept(outsideGuestAccept) + .build(); + } + public static Exercise createExerciseWithAddr(Party party, LocalDate date) { return createExerciseWithAddr(party, date, 10); } diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java index eea7b8dee..1ed7b6cf3 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java @@ -12,6 +12,7 @@ import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.global.enums.Role; +import java.time.LocalDate; import java.time.LocalDateTime; public class MemberFixture { @@ -27,6 +28,18 @@ public static Member createMember(String nickname, Gender gender, Level level, L .build(); } + public static Member createMember(String nickname, Gender gender, Level level, Long socialId, LocalDate birth) { + return Member.builder() + .memberName(nickname) + .nickname(nickname) + .gender(gender) + .level(level) + .birth(birth) + .isActive(MemberStatus.ACTIVE) + .socialId(socialId) + .build(); + } + public static Member createMemberWithName(String memberName, String nickname, Gender gender, Level level, Long socialId) { return Member.builder() .memberName(memberName) From 4c71961bf645b18a0aa3090812863294d9303344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:37:28 +0900 Subject: [PATCH 09/20] =?UTF-8?q?[feat/#540]=20FCM=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#541)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 8 +- .github/workflows/ci.yml | 2 +- scripts/deploy.sh | 2 + .../demo/domain/member/domain/Member.java | 7 + .../controller/NotificationController.java | 144 +++++---- .../notification/dto/FcmTokenRequestDTO.java | 9 + .../exception/NotificationErrorCode.java | 4 +- .../domain/notification/fcm/FcmService.java | 54 ++++ .../service/NotificationCommandService.java | 279 +++++++++--------- .../demo/global/config/FirebaseConfig.java | 50 ++++ src/main/resources/application-local.yml | 3 + src/main/resources/application-prod.yml | 3 + src/main/resources/application-staging.yml | 3 + .../notification/fcm/FcmServiceTest.java | 99 +++++++ .../demo/support/IntegrationTestConfig.java | 8 + 15 files changed, 464 insertions(+), 211 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java create mode 100644 src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java create mode 100644 src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java create mode 100644 src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 578dbebe7..9d3892c74 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,11 +28,6 @@ jobs: echo "DOCKER_TAG=staging" >> $GITHUB_OUTPUT fi - - name: Create Firebase Key File - run: | - mkdir -p src/main/resources/firebase - echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}' > src/main/resources/firebase/cockple-fcm-firebase-adminsdk-fbsvc-5cac44bb029.json - - name: Build with Gradle run: | @@ -77,7 +72,7 @@ jobs: envs: >- DB_PASSWORD,GCS_BUCKET, KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI_PROD,KAKAO_REDIRECT_URI_STAGING,KAKAO_ADMIN_KEY, - JWT_SECRET_KEY + JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY script: | chmod +x /home/ubuntu/cockple/scripts/deploy.sh bash /home/ubuntu/cockple/scripts/deploy.sh \ @@ -92,3 +87,4 @@ jobs: KAKAO_REDIRECT_URI_STAGING: ${{ secrets.KAKAO_REDIRECT_URI_STAGING }} KAKAO_ADMIN_KEY: ${{ secrets.KAKAO_ADMIN_KEY }} JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46dd991d8..e271fa7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Create Firebase Key File run: | mkdir -p src/main/resources/firebase - echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}' > src/main/resources/firebase/cockple-fcm-firebase-adminsdk-fbsvc-5cac44bb029.json + echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}' > src/main/resources/firebase/cockple-1a83e-firebase-adminsdk-fbsvc-212ce01565.json - name: Grant execute permission for Gradle run: chmod +x gradlew diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 32fa00406..7a96ce079 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -22,7 +22,9 @@ KAKAO_REDIRECT_URI_PROD=${KAKAO_REDIRECT_URI_PROD} KAKAO_REDIRECT_URI_STAGING=${KAKAO_REDIRECT_URI_STAGING} KAKAO_ADMIN_KEY=${KAKAO_ADMIN_KEY} JWT_SECRET_KEY=${JWT_SECRET_KEY} +FIREBASE_SERVICE_ACCOUNT_KEY=${FIREBASE_SERVICE_ACCOUNT_KEY} EOF +echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json echo "=== 배포 전 상태 ===" sudo docker ps diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java index 041d34b9e..1b5456858 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/Member.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/Member.java @@ -56,6 +56,7 @@ public class Member extends BaseEntity { @Column(nullable = false) private Long socialId; // 카카오에서 받아온 고유id + private String fcmToken; @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) @Builder.Default @@ -175,12 +176,18 @@ public boolean hasDuplicateAddr(CreateMemberAddrRequestDTO requestDTO) { public void withdraw() { this.isActive = MemberStatus.INACTIVE; this.refreshToken = null; + this.fcmToken = null; } public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + // FCM 토큰 업데이트 메서드 + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + public void rejoin() { this.isActive = MemberStatus.ACTIVE; initField(); diff --git a/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java b/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java index fb6a5956a..3760956a7 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java +++ b/src/main/java/umc/cockple/demo/domain/notification/controller/NotificationController.java @@ -1,64 +1,80 @@ -package umc.cockple.demo.domain.notification.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO; -import umc.cockple.demo.domain.notification.service.NotificationCommandService; -import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO; -import umc.cockple.demo.domain.notification.service.NotificationQueryService; -import umc.cockple.demo.global.response.BaseResponse; -import umc.cockple.demo.global.response.code.status.CommonSuccessCode; -import umc.cockple.demo.global.security.utils.SecurityUtil; - -import java.util.List; - -import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*; - -@RestController -@RequestMapping("/api") -@RequiredArgsConstructor -@Validated -@Tag(name = "Notification", description = "알림 API") -public class NotificationController { - - private final NotificationQueryService notificationQueryService; - private final NotificationCommandService notificationCommandService; - - @GetMapping("/notifications") - @Operation(summary = "내 알림 전체 조회", - description = "사용자에게 온 알림 전체를 조회합니다. ") - public BaseResponse> getAllNotifications() { - - Long memberId = SecurityUtil.getCurrentMemberId(); - - return BaseResponse.success(CommonSuccessCode.OK, notificationQueryService.getAllNotifications(memberId)); - } - - - - @GetMapping("/notifications/count") - @Operation(summary = "안 읽은 알림 존재여부 조회", - description = "사용자가 읽지 않은 알림이 있는지 확인합니다. 존재 시 알림 아이콘에 빨간 점이 표시됩니다 ") - public BaseResponse checkUnReadNotification() { - - Long memberId = SecurityUtil.getCurrentMemberId(); - - return BaseResponse.success(CommonSuccessCode.OK,notificationQueryService.checkUnreadNotification(memberId)); - } - - - - @PatchMapping("/notifications/{notificationId}") - @Operation(summary = "내 특정 알림 조회 및 읽음 처리", - description = "특정 알림을 조회하고 읽음 처리를 진행합니다. ") - public BaseResponse markAsReadNotification(@PathVariable Long notificationId, - Request type) { - Long memberId = SecurityUtil.getCurrentMemberId(); - - return BaseResponse.success(CommonSuccessCode.OK, notificationCommandService.markAsReadNotification(memberId, notificationId, type.type())); - } - -} +package umc.cockple.demo.domain.notification.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO; +import umc.cockple.demo.domain.notification.dto.FcmTokenRequestDTO; +import umc.cockple.demo.domain.notification.fcm.FcmService; +import umc.cockple.demo.domain.notification.service.NotificationCommandService; +import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO; +import umc.cockple.demo.domain.notification.service.NotificationQueryService; +import umc.cockple.demo.global.response.BaseResponse; +import umc.cockple.demo.global.response.code.status.CommonSuccessCode; +import umc.cockple.demo.global.security.utils.SecurityUtil; + +import java.util.List; + +import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Validated +@Tag(name = "Notification", description = "알림 API") +public class NotificationController { + + private final NotificationQueryService notificationQueryService; + private final NotificationCommandService notificationCommandService; + private final FcmService fcmService; + + @GetMapping("/notifications") + @Operation(summary = "내 알림 전체 조회", + description = "사용자에게 온 알림 전체를 조회합니다. ") + public BaseResponse> getAllNotifications() { + + Long memberId = SecurityUtil.getCurrentMemberId(); + + return BaseResponse.success(CommonSuccessCode.OK, notificationQueryService.getAllNotifications(memberId)); + } + + + + @GetMapping("/notifications/count") + @Operation(summary = "안 읽은 알림 존재여부 조회", + description = "사용자가 읽지 않은 알림이 있는지 확인합니다. 존재 시 알림 아이콘에 빨간 점이 표시됩니다 ") + public BaseResponse checkUnReadNotification() { + + Long memberId = SecurityUtil.getCurrentMemberId(); + + return BaseResponse.success(CommonSuccessCode.OK,notificationQueryService.checkUnreadNotification(memberId)); + } + + + + @PatchMapping("/notifications/{notificationId}") + @Operation(summary = "내 특정 알림 조회 및 읽음 처리", + description = "특정 알림을 조회하고 읽음 처리를 진행합니다. ") + public BaseResponse markAsReadNotification(@PathVariable Long notificationId, + Request type) { + Long memberId = SecurityUtil.getCurrentMemberId(); + + return BaseResponse.success(CommonSuccessCode.OK, notificationCommandService.markAsReadNotification(memberId, notificationId, type.type())); + } + + + // ========== FCM 토큰 등록/갱신 API ========== + @PatchMapping("/notifications/fcm-token") + @Operation(summary = "FCM 토큰 등록", + description = "디바이스의 FCM 토큰을 등록하거나 갱신합니다. 알림 권한 거부 시 null을 전달해 토큰을 삭제합니다.") + public BaseResponse registerFcmToken(@RequestBody @Valid FcmTokenRequestDTO request) { + Long memberId = SecurityUtil.getCurrentMemberId(); + fcmService.registerFcmToken(memberId, request.fcmToken()); + return BaseResponse.success(CommonSuccessCode.OK, null); + } + + +} diff --git a/src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java b/src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java new file mode 100644 index 000000000..a96777e63 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/notification/dto/FcmTokenRequestDTO.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.notification.dto; + +import jakarta.validation.constraints.NotBlank; + +public record FcmTokenRequestDTO( + @NotBlank(message = "FCM 토큰은 필수입니다.") + String fcmToken +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java b/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java index 537fde9d8..0d1e78118 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/notification/exception/NotificationErrorCode.java @@ -18,11 +18,11 @@ public enum NotificationErrorCode implements BaseErrorCode { */ NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION201", "해당 알림이 존재하지 않습니다."), - - NOTIFICATION_NOT_OWNED(HttpStatus.UNAUTHORIZED, "NOTIFICATION301", "해당 알림에 대한 권한이 없습니다."), + INVALID_NOTIFICATION_DATA(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION501", "데이터 직렬화에 실패했습니다."), + FAIL_INIT_FIREBASE(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION502", "Firebase 초기화에 실패했습니다.") ; diff --git a/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java b/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java new file mode 100644 index 000000000..ab84bdef2 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/notification/fcm/FcmService.java @@ -0,0 +1,54 @@ +package umc.cockple.demo.domain.notification.fcm; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FcmService { + + private final MemberRepository memberRepository; + private final FirebaseMessaging firebaseMessaging; + + @Transactional + public void registerFcmToken(Long memberId, String fcmToken) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + member.updateFcmToken(fcmToken); + log.info("FCM 토큰 등록 완료 - memberId: {}", memberId); + } + + public void sendNotification(Member member, String title, String content) { + String fcmToken = member.getFcmToken(); + if (fcmToken == null || fcmToken.isBlank()) { + log.info("FCM 토큰 없음 - memberId: {}, 알림 전송 생략", member.getId()); + return; + } + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(content) + .build()) + .build(); + + try { + firebaseMessaging.send(message); + log.info("FCM 전송 완료 - memberId: {}", member.getId()); + } catch (FirebaseMessagingException e) { + log.error("FCM 전송 실패 - memberId: {}, error: {}", member.getId(), e.getMessage()); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java index 78f59d9ee..6f4c3c594 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java @@ -1,138 +1,141 @@ -package umc.cockple.demo.domain.notification.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.repository.MemberRepository; -import umc.cockple.demo.domain.notification.domain.Notification; -import umc.cockple.demo.domain.notification.dto.CreateNotificationRequestDTO; -import umc.cockple.demo.domain.notification.enums.NotificationTarget; -import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; -import umc.cockple.demo.domain.notification.exception.NotificationException; -import umc.cockple.demo.domain.notification.repository.NotificationRepository; -import umc.cockple.demo.domain.notification.enums.NotificationType; -import umc.cockple.demo.domain.party.domain.Party; -import umc.cockple.demo.domain.party.exception.PartyErrorCode; -import umc.cockple.demo.domain.party.exception.PartyException; -import umc.cockple.demo.domain.party.repository.PartyRepository; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.time.format.TextStyle; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class NotificationCommandService { - - private final NotificationRepository notificationRepository; - private final MemberRepository memberRepository; - private final PartyRepository partyRepository; - private final NotificationMessageGenerator notificationMessageGenerator; - private final ObjectMapper objectMapper; - - // 알림 타입 변경 (초대 수락, 거절에 사용) - public Response markAsReadNotification(Long memberId, Long notificationId, NotificationType type) { - Notification notification = findByNotificationId(notificationId); - - if (!notification.getMember().getId().equals(memberId)) { - throw new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_OWNED); - } - - notification.changeType(type); - - notification.read(); - - return new Response(notification.getType()); - } - - public void createNotification(CreateNotificationRequestDTO dto) { - try { - - Member member = dto.member(); - List bookmarks = notificationRepository.findAllByMember(member); - if (bookmarks.size() >= 50) { - // INVITE타입이 아니면서 가장 오래된 거 삭제 - notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(member, NotificationType.INVITE) - .ifPresent(notificationRepository::delete); - } - - Party party = partyRepository.findById(dto.partyId()) - .orElseThrow(() -> new PartyException(PartyErrorCode.PARTY_NOT_FOUND)); - - Map context = new HashMap<>(); - if (dto.exerciseId() != null) context.put("exerciseId", dto.exerciseId()); - if (dto.exerciseDate() != null) context.put("exerciseDate", dto.exerciseDate()); - if (dto.invitationId() != null) context.put("invitationId", dto.invitationId()); - - String content; - String title = party.getPartyName(); - if (dto.target() == NotificationTarget.EXERCISE_DELETE) { - String result = extractExerciseDateFormat(dto.exerciseDate()); - content = notificationMessageGenerator.generateExerciseDeletedMessage(result); - } else if (dto.target() == NotificationTarget.EXERCISE_MODIFY) { - String result = extractExerciseDateFormat(dto.exerciseDate()); - content = notificationMessageGenerator.generateExerciseChangedMessage(result); - } else if (dto.target() == NotificationTarget.EXERCISE_ATTENDANCE) { - content = notificationMessageGenerator.generateExerciseAttendChangedMessage(); - } else if (dto.target() == NotificationTarget.PARTY_DELETE) { - content = notificationMessageGenerator.generatePartyDeletedMessage(); - } else if (dto.target() == NotificationTarget.PARTY_MODIFY) { - content = notificationMessageGenerator.generatePartyInfoChangedMessage(); - } else if (dto.target() == NotificationTarget.PARTY_INVITE) { - content = notificationMessageGenerator.generateInviteMessage(party.getPartyName()); - title = "새로운 모임"; - } else if (dto.target() == NotificationTarget.PARTY_INVITE_APPROVED) { - content = notificationMessageGenerator.generateInviteApprovedMessage(dto.subjectName()); - } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_ASSIGNED) { - content = notificationMessageGenerator.generateSubOwnerAssignedMessage(dto.subjectName()); - } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_RELEASED) { - content = notificationMessageGenerator.generateSubOwnerReleasedMessage(dto.subjectName()); - } else { - content = notificationMessageGenerator.generateJoinRequestApprovedMessage(); - } - - String data = objectMapper.writeValueAsString(context); - - Notification notification = Notification.builder() - .member(dto.member()) - .partyId(dto.partyId()) - .title(title) - .content(content) - .type(dto.target().getDefaultType()) - .isRead(false) - .imageKey(party.getPartyImg() != null ? party.getPartyImg().getImgKey() : null) - .data(data) - .build(); - - notificationRepository.save(notification); - - } catch (JsonProcessingException e) { - throw new NotificationException(NotificationErrorCode.INVALID_NOTIFICATION_DATA); - } - } - - private Notification findByNotificationId(Long notification) { - return notificationRepository.findById(notification) - .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); - } - - private String extractExerciseDateFormat(LocalDate date) { - // 날짜 요일 포매팅 (MM.dd(요일)) - String format = date.format(DateTimeFormatter.ofPattern("MM.dd")); - String day = date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREAN); - return format + "(" + day + ")"; - } - -} +package umc.cockple.demo.domain.notification.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.dto.CreateNotificationRequestDTO; +import umc.cockple.demo.domain.notification.enums.NotificationTarget; +import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; +import umc.cockple.demo.domain.notification.exception.NotificationException; +import umc.cockple.demo.domain.notification.fcm.FcmService; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class NotificationCommandService { + + private final NotificationRepository notificationRepository; + private final MemberRepository memberRepository; + private final PartyRepository partyRepository; + private final NotificationMessageGenerator notificationMessageGenerator; + private final ObjectMapper objectMapper; + private final FcmService fcmService; + + // 알림 타입 변경 (초대 수락, 거절에 사용) + public Response markAsReadNotification(Long memberId, Long notificationId, NotificationType type) { + Notification notification = findByNotificationId(notificationId); + + if (!notification.getMember().getId().equals(memberId)) { + throw new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_OWNED); + } + + notification.changeType(type); + + notification.read(); + + return new Response(notification.getType()); + } + + public void createNotification(CreateNotificationRequestDTO dto) { + try { + + Member member = dto.member(); + List bookmarks = notificationRepository.findAllByMember(member); + if (bookmarks.size() >= 50) { + // INVITE타입이 아니면서 가장 오래된 거 삭제 + notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(member, NotificationType.INVITE) + .ifPresent(notificationRepository::delete); + } + + Party party = partyRepository.findById(dto.partyId()) + .orElseThrow(() -> new PartyException(PartyErrorCode.PARTY_NOT_FOUND)); + + Map context = new HashMap<>(); + if (dto.exerciseId() != null) context.put("exerciseId", dto.exerciseId()); + if (dto.exerciseDate() != null) context.put("exerciseDate", dto.exerciseDate()); + if (dto.invitationId() != null) context.put("invitationId", dto.invitationId()); + + String content; + String title = party.getPartyName(); + if (dto.target() == NotificationTarget.EXERCISE_DELETE) { + String result = extractExerciseDateFormat(dto.exerciseDate()); + content = notificationMessageGenerator.generateExerciseDeletedMessage(result); + } else if (dto.target() == NotificationTarget.EXERCISE_MODIFY) { + String result = extractExerciseDateFormat(dto.exerciseDate()); + content = notificationMessageGenerator.generateExerciseChangedMessage(result); + } else if (dto.target() == NotificationTarget.EXERCISE_ATTENDANCE) { + content = notificationMessageGenerator.generateExerciseAttendChangedMessage(); + } else if (dto.target() == NotificationTarget.PARTY_DELETE) { + content = notificationMessageGenerator.generatePartyDeletedMessage(); + } else if (dto.target() == NotificationTarget.PARTY_MODIFY) { + content = notificationMessageGenerator.generatePartyInfoChangedMessage(); + } else if (dto.target() == NotificationTarget.PARTY_INVITE) { + content = notificationMessageGenerator.generateInviteMessage(party.getPartyName()); + title = "새로운 모임"; + } else if (dto.target() == NotificationTarget.PARTY_INVITE_APPROVED) { + content = notificationMessageGenerator.generateInviteApprovedMessage(dto.subjectName()); + } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_ASSIGNED) { + content = notificationMessageGenerator.generateSubOwnerAssignedMessage(dto.subjectName()); + } else if (dto.target() == NotificationTarget.PARTY_SUBOWNER_RELEASED) { + content = notificationMessageGenerator.generateSubOwnerReleasedMessage(dto.subjectName()); + } else { + content = notificationMessageGenerator.generateJoinRequestApprovedMessage(); + } + + String data = objectMapper.writeValueAsString(context); + + Notification notification = Notification.builder() + .member(dto.member()) + .partyId(dto.partyId()) + .title(title) + .content(content) + .type(dto.target().getDefaultType()) + .isRead(false) + .imageKey(party.getPartyImg() != null ? party.getPartyImg().getImgKey() : null) + .data(data) + .build(); + + notificationRepository.save(notification); + fcmService.sendNotification(member, title, content); + + } catch (JsonProcessingException e) { + throw new NotificationException(NotificationErrorCode.INVALID_NOTIFICATION_DATA); + } + } + + private Notification findByNotificationId(Long notification) { + return notificationRepository.findById(notification) + .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + } + + private String extractExerciseDateFormat(LocalDate date) { + // 날짜 요일 포매팅 (MM.dd(요일)) + String format = date.format(DateTimeFormatter.ofPattern("MM.dd")); + String day = date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREAN); + return format + "(" + day + ")"; + } + +} diff --git a/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java b/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java new file mode 100644 index 000000000..45bda381c --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java @@ -0,0 +1,50 @@ +package umc.cockple.demo.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; +import umc.cockple.demo.domain.notification.exception.NotificationException; +import umc.cockple.demo.global.exception.GlobalExceptionHandler; + +@Slf4j +@Configuration +@Profile("!integrationtest") +public class FirebaseConfig { + + @Value("${firebase.key-path}") + private String keyPath; + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) { + log.warn("FirebaseApp이 이미 초기화되어 있습니다. 기존 인스턴스를 반환합니다."); + return FirebaseApp.getInstance(); + } + + try (InputStream serviceAccount = new FileInputStream(keyPath)) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + return FirebaseApp.initializeApp(options); + } catch (IOException e) { + log.error("Firebase 초기화 실패 - 키 파일을 읽을 수 없습니다. 경로: {}", keyPath, e); + throw new NotificationException(NotificationErrorCode.FAIL_INIT_FIREBASE); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { + return FirebaseMessaging.getInstance(firebaseApp); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index d83c9adf4..021ad7457 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -11,3 +11,6 @@ spring: jpa: hibernate: ddl-auto: update + +firebase: + key-path: src/main/resources/firebase/cockple-1a83e-firebase-adminsdk-fbsvc-212ce01565.json diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6a100ef4a..b549030f9 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -10,3 +10,6 @@ spring: redis: host: cockple-redis database: 0 + +firebase: + key-path: /app/firebase/firebase-service-account.json diff --git a/src/main/resources/application-staging.yml b/src/main/resources/application-staging.yml index 42edab0cd..5cffb76d5 100644 --- a/src/main/resources/application-staging.yml +++ b/src/main/resources/application-staging.yml @@ -10,3 +10,6 @@ spring: redis: host: cockple-redis database: 1 + +firebase: + key-path: /app/firebase/firebase-service-account.json diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java new file mode 100644 index 000000000..25a8c96ce --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmServiceTest.java @@ -0,0 +1,99 @@ +package umc.cockple.demo.domain.notification.fcm; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FcmService") +class FcmServiceTest { + + @InjectMocks + private FcmService fcmService; + + @Mock + private FirebaseMessaging firebaseMessaging; + + private Member memberWithToken; + private Member memberWithoutToken; + + @BeforeEach + void setUp() { + memberWithToken = MemberFixture.createMember("토큰있음", Gender.MALE, Level.C, 1001L); + ReflectionTestUtils.setField(memberWithToken, "id", 1L); + ReflectionTestUtils.setField(memberWithToken, "fcmToken", "valid-fcm-token"); + + memberWithoutToken = MemberFixture.createMember("토큰없음", Gender.MALE, Level.C, 1002L); + ReflectionTestUtils.setField(memberWithoutToken, "id", 2L); + } + + @Nested + @DisplayName("sendNotification") + class SendNotification { + + @Nested + @DisplayName("전송 생략") + class Skip { + + @Test + @DisplayName("fcmToken이_null이면_전송하지_않는다") + void fcmToken이_null이면_전송하지_않는다() throws FirebaseMessagingException { + // when + fcmService.sendNotification(memberWithoutToken, "제목", "내용"); + + // then + then(firebaseMessaging).should(never()).send(any()); + } + + @Test + @DisplayName("fcmToken이_빈_문자열이면_전송하지_않는다") + void fcmToken이_빈_문자열이면_전송하지_않는다() throws FirebaseMessagingException { + // given + ReflectionTestUtils.setField(memberWithoutToken, "fcmToken", ""); + + // when + fcmService.sendNotification(memberWithoutToken, "제목", "내용"); + + // then + then(firebaseMessaging).should(never()).send(any()); + } + } + + @Nested + @DisplayName("전송 실패") + class Failure { + + @Test + @DisplayName("Firebase_전송_실패해도_예외를_던지지_않는다") + void Firebase_전송_실패해도_예외를_던지지_않는다() throws FirebaseMessagingException { + // given + FirebaseMessagingException exception = mock(FirebaseMessagingException.class); + given(firebaseMessaging.send(any())).willThrow(exception); + + // when & then + assertThatCode(() -> fcmService.sendNotification(memberWithToken, "제목", "내용")) + .doesNotThrowAnyException(); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java index e245f83e5..5c65162b4 100644 --- a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java +++ b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java @@ -1,5 +1,6 @@ package umc.cockple.demo.support; +import com.google.firebase.messaging.FirebaseMessaging; import com.redis.testcontainers.RedisContainer; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; @@ -7,6 +8,8 @@ import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import static org.mockito.Mockito.mock; + @TestConfiguration(proxyBeanMethods = false) public class IntegrationTestConfig { @@ -32,4 +35,9 @@ MySQLContainer mySQLContainer() { RedisContainer redisContainer() { return redis; } + + @Bean + FirebaseMessaging firebaseMessaging() { + return mock(FirebaseMessaging.class); + } } From 3c6d706bf0fa30cf23b793d5cdfff2ca9549d8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:25:19 +0900 Subject: [PATCH 10/20] =?UTF-8?q?[feat/#540]=20Firebase=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20(#542)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: firebase 주입방식 변경 * chore: 설정파일 추가 * chore: firebase 키 재발급 * feat: firebaseConfig파일 생성 * feat: 설정파일 추가 * feat: FCM 알림 기능 구현 * test: 단위테스트 작성 * chore: valid추가 * test: 통합테스트 config에 mock 추가 * chore: 환경변수 주입 방식으로 변경 --- .../cockple/demo/global/config/FirebaseConfig.java | 13 +++++++------ src/main/resources/application-local.yml | 2 +- src/main/resources/application-prod.yml | 2 +- src/main/resources/application-staging.yml | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java b/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java index 45bda381c..ba57db6dd 100644 --- a/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java +++ b/src/main/java/umc/cockple/demo/global/config/FirebaseConfig.java @@ -10,20 +10,20 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; -import java.io.FileInputStream; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; import umc.cockple.demo.domain.notification.exception.NotificationException; -import umc.cockple.demo.global.exception.GlobalExceptionHandler; @Slf4j @Configuration @Profile("!integrationtest") public class FirebaseConfig { - @Value("${firebase.key-path}") - private String keyPath; + @Value("${firebase.service-account-key}") + private String serviceAccountKey; @Bean public FirebaseApp firebaseApp() throws IOException { @@ -32,13 +32,14 @@ public FirebaseApp firebaseApp() throws IOException { return FirebaseApp.getInstance(); } - try (InputStream serviceAccount = new FileInputStream(keyPath)) { + try (InputStream serviceAccount = new ByteArrayInputStream( + serviceAccountKey.getBytes(StandardCharsets.UTF_8))) { FirebaseOptions options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(serviceAccount)) .build(); return FirebaseApp.initializeApp(options); } catch (IOException e) { - log.error("Firebase 초기화 실패 - 키 파일을 읽을 수 없습니다. 경로: {}", keyPath, e); + log.error("Firebase 초기화 실패 - 환경변수를 읽을 수 없습니다.", e); throw new NotificationException(NotificationErrorCode.FAIL_INIT_FIREBASE); } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 021ad7457..6832072a9 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -13,4 +13,4 @@ spring: ddl-auto: update firebase: - key-path: src/main/resources/firebase/cockple-1a83e-firebase-adminsdk-fbsvc-212ce01565.json + service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index b549030f9..4bbe9aa24 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -12,4 +12,4 @@ spring: database: 0 firebase: - key-path: /app/firebase/firebase-service-account.json + service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY} diff --git a/src/main/resources/application-staging.yml b/src/main/resources/application-staging.yml index 5cffb76d5..e07bbae70 100644 --- a/src/main/resources/application-staging.yml +++ b/src/main/resources/application-staging.yml @@ -12,4 +12,4 @@ spring: database: 1 firebase: - key-path: /app/firebase/firebase-service-account.json + service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY} From 46a802a9a8ee6c75a2684f33bd21c884c4dbee95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:35:59 +0900 Subject: [PATCH 11/20] =?UTF-8?q?[feat/#540]=20firebase=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=A3=BC=EC=9E=85=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20docker-comp?= =?UTF-8?q?ose=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95=20(#543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: firebase 주입방식 변경 * chore: 설정파일 추가 * chore: firebase 키 재발급 * feat: firebaseConfig파일 생성 * feat: 설정파일 추가 * feat: FCM 알림 기능 구현 * test: 단위테스트 작성 * chore: valid추가 * test: 통합테스트 config에 mock 추가 * chore: 환경변수 주입 방식으로 변경 * chore: docker-compose 파일 수정 --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 605c9893b..d7f0df5ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,7 @@ services: KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI_PROD} KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} + FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} depends_on: mysql: condition: service_healthy @@ -85,6 +86,7 @@ services: KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI_STAGING} KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY} JWT_SECRET_KEY: ${JWT_SECRET_KEY} + FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY} depends_on: mysql: condition: service_healthy From a7eeaeff640afcba1783dd6f114f8574157f7068 Mon Sep 17 00:00:00 2001 From: Yumin Kwon Date: Tue, 17 Mar 2026 12:02:48 +0900 Subject: [PATCH 12/20] =?UTF-8?q?[debug/#544]=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20(=EC=BD=95=ED=94=8C?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=EB=AA=A8=EB=93=9C)=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EB=88=84=EB=9D=BD=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/domain/party/service/PartyQueryServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java index eaf47b21c..d36e247f0 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyQueryServiceImpl.java @@ -307,7 +307,7 @@ private Slice getCockpleRecommendedParties(Long memberId, String search, //이름 검색 필터 적용 List searchedParties = filterByName(filteredParties, search); //키워드 일치 개수로 정렬 - List sortedParties = sortPartiesByKeywordMatch(filteredParties, partiesInfo.keywords()); + List sortedParties = sortPartiesByKeywordMatch(searchedParties, partiesInfo.keywords()); //수동으로 페이징 Slice partySlice = paginate(sortedParties, pageable); From fb3729ff9e385f59d417ab39ae00c999cc3a4b98 Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:10:51 +0900 Subject: [PATCH 13/20] =?UTF-8?q?[debug/#547]=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C?= =?UTF-8?q?=20=EB=82=B4=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=84=EB=B6=80=20=EB=82=B4=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20(#548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * debug: 프로필 이름 변경 시 상대방의 채팅방 목록에서 내 이름이 변경되도록 버그 수정 * test: 프로필 수정 메서드에서 채팅방 이름 변경 로직 단위 테스트 작성 * improve: 통합 테스트에서 실제 스토리지에 데이터를 넣는 건 적절하지 않으므로 mock과 stub으로 대체 * test: 프로필 수정 API 통합 테스트 작성 --- .../repository/ChatRoomMemberRepository.java | 13 +- .../repository/MemberKeywordRepository.java | 4 + .../member/service/MemberCommandService.java | 6 +- .../integration/MemberIntegrationTest.java | 218 ++++++++++++++++-- .../service/MemberCommandServiceTest.java | 30 ++- .../demo/support/fixture/ChatFixture.java | 10 + 6 files changed, 249 insertions(+), 32 deletions(-) diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java index 42b343533..17e8c0f1f 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java @@ -1,6 +1,7 @@ package umc.cockple.demo.domain.chat.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; @@ -26,8 +27,6 @@ Optional findByChatRoomIdAndMemberId( List findByChatRoomId(Long id); - List findAllByMemberId(Long id); - @Query(""" SELECT crm FROM ChatRoomMember crm JOIN FETCH crm.member m @@ -62,5 +61,15 @@ Optional findCounterPartWithMember( @Query("SELECT crm.member.id FROM ChatRoomMember crm WHERE crm.chatRoom.id = :chatRoomId") List findMemberIdsByChatRoomId(Long chatRoomId); + + @Query(""" + SELECT counterPart FROM ChatRoomMember counterPart + WHERE counterPart.chatRoom.type = 'DIRECT' + AND counterPart.member.id != :memberId + AND counterPart.chatRoom.id IN ( + SELECT mine.chatRoom.id FROM ChatRoomMember mine WHERE mine.member.id = :memberId + ) + """) + List findDirectChatCounterParts(@Param("memberId") Long memberId); } diff --git a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java index 8c772d55b..930259ffb 100644 --- a/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java +++ b/src/main/java/umc/cockple/demo/domain/member/repository/MemberKeywordRepository.java @@ -4,7 +4,11 @@ import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberKeyword; +import java.util.List; + public interface MemberKeywordRepository extends JpaRepository { void deleteAllByMember(Member member); + + List findAllByMemberId(Long memberId); } diff --git a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java index 8dfcc3661..7457c6020 100644 --- a/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/member/service/MemberCommandService.java @@ -5,7 +5,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.member.domain.*; import umc.cockple.demo.domain.member.dto.MemberDetailInfoRequestDTO; @@ -158,9 +157,8 @@ public void updateProfile(UpdateProfileRequestDTO requestDto, Long memberId) { } } - //chatRoomMember의 displayName도 같이 업데이트 - List chatRoomMembers = chatRoomMemberRepository.findAllByMemberId(member.getId()); - chatRoomMembers.forEach(crm -> crm.updateDisplayName(requestDto.memberName())); + chatRoomMemberRepository.findDirectChatCounterParts(member.getId()) + .forEach(crm -> crm.updateDisplayName(requestDto.memberName())); } diff --git a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java index 6bedc6309..bba271356 100644 --- a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java @@ -6,25 +6,23 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.contest.domain.Contest; import umc.cockple.demo.domain.contest.enums.MedalType; import umc.cockple.demo.domain.contest.repository.ContestRepository; -import umc.cockple.demo.domain.member.domain.Member; -import umc.cockple.demo.domain.member.domain.MemberAddr; -import umc.cockple.demo.domain.member.domain.MemberExercise; -import umc.cockple.demo.domain.member.domain.MemberKeyword; -import umc.cockple.demo.domain.member.domain.ProfileImg; +import umc.cockple.demo.domain.exercise.enums.ExerciseMemberShipStatus; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.*; import umc.cockple.demo.domain.member.dto.CreateMemberAddrDTO; +import umc.cockple.demo.domain.member.dto.UpdateProfileRequestDTO; import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.member.exception.MemberErrorCode; -import umc.cockple.demo.domain.member.repository.MemberAddrRepository; -import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; -import umc.cockple.demo.domain.member.repository.MemberKeywordRepository; -import umc.cockple.demo.domain.member.repository.MemberPartyRepository; -import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.member.repository.*; import umc.cockple.demo.domain.party.domain.Party; import umc.cockple.demo.domain.party.domain.PartyAddr; -import umc.cockple.demo.domain.exercise.enums.ExerciseMemberShipStatus; import umc.cockple.demo.domain.party.enums.ParticipationType; import umc.cockple.demo.domain.party.repository.PartyAddrRepository; import umc.cockple.demo.domain.party.repository.PartyRepository; @@ -35,15 +33,20 @@ import umc.cockple.demo.global.oauth2.service.KakaoOauthService; import umc.cockple.demo.support.IntegrationTestBase; import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ChatFixture; import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; import java.time.LocalDate; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; +import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class MemberIntegrationTest extends IntegrationTestBase { @@ -55,13 +58,14 @@ class MemberIntegrationTest extends IntegrationTestBase { @Autowired PartyRepository partyRepository; @Autowired PartyAddrRepository partyAddrRepository; - // withdrawMember에서 카카오 연결 끊기 API 호출을 막기 위해 Mock 처리 - @MockitoBean - KakaoOauthService kakaoOauthService; + @MockitoBean KakaoOauthService kakaoOauthService; + @MockitoBean FileService fileService; @Autowired ContestRepository contestRepository; @Autowired MemberExerciseRepository memberExerciseRepository; @Autowired MemberKeywordRepository memberKeywordRepository; + @Autowired ChatRoomRepository chatRoomRepository; + @Autowired ChatRoomMemberRepository chatRoomMemberRepository; private Member member; @@ -72,6 +76,7 @@ void setUp() { @AfterEach void tearDown() { + chatRoomRepository.deleteAll(); // cascade: ChatRoomMember 함께 삭제 memberPartyRepository.deleteAll(); partyRepository.deleteAll(); partyAddrRepository.deleteAll(); @@ -134,7 +139,6 @@ void subManager_cannotWithdraw() throws Exception { } } - @Nested @DisplayName("GET /api/profile/{memberId} - 타인 프로필 조회") class GetProfile { @@ -147,6 +151,9 @@ class Success { @DisplayName("200 - 모든 필드가 정상 반환된다") void getProfile_모든_필드가_정상_반환된다() throws Exception { // given + given(fileService.getUrlFromKey("profile/test-key.jpg")) + .willReturn("https://storage.googleapis.com/test-bucket/profile/test-key.jpg"); + Member freshMember = memberRepository.save(Member.builder() .memberName("홍길동") .nickname("홍길동") @@ -260,6 +267,9 @@ void getMyProfile_success() throws Exception { @DisplayName("200 - 모든 필드가 정상 반환된다") void getMyProfile_모든_필드가_정상_반환된다() throws Exception { // given + given(fileService.getUrlFromKey("profile/test-key.jpg")) + .willReturn("https://storage.googleapis.com/test-bucket/profile/test-key.jpg"); + Member freshMember = memberRepository.save(Member.builder() .memberName("홍길동") .nickname("홍길동") @@ -374,6 +384,182 @@ void noMainAddress_fail() throws Exception { } } + @Nested + @DisplayName("PATCH /api/my/profile - 프로필 수정") + class UpdateProfile { + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("200 - 모든 필드가 정상 업데이트된다") + void 모든_필드가_정상_업데이트된다() throws Exception { + // given - 기존 키워드 등록 + memberKeywordRepository.save(MemberKeyword.builder() + .member(member).keyword(Keyword.FREE).build()); + + UpdateProfileRequestDTO request = new UpdateProfileRequestDTO( + "김길동", LocalDate.of(1995, 6, 15), Level.B, + List.of(Keyword.FRIENDSHIP, Keyword.MANAGER_MATCH), "profile/new-key.jpg"); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // when + mockMvc.perform(patch("/api/my/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then - DB에서 모든 필드 검증 + Member updated = memberRepository.findMemberWithProfileById(member.getId()).orElseThrow(); + assertThat(updated.getMemberName()).isEqualTo("김길동"); + assertThat(updated.getBirth()).isEqualTo(LocalDate.of(1995, 6, 15)); + assertThat(updated.getLevel()).isEqualTo(Level.B); + assertThat(updated.getProfileImg()).isNotNull(); + assertThat(updated.getProfileImg().getImgKey()).isEqualTo("profile/new-key.jpg"); + + List keywords = memberKeywordRepository.findAllByMemberId(member.getId()); + assertThat(keywords).hasSize(2); + assertThat(keywords.stream().map(MemberKeyword::getKeyword).toList()) + .containsExactlyInAnyOrder(Keyword.FRIENDSHIP, Keyword.MANAGER_MATCH); + } + + @Test + @DisplayName("200 - imgKey가 없으면 이미지 없이 프로필이 업데이트된다") + void imgKey가_없으면_이미지_없이_프로필이_업데이트된다() throws Exception { + // given + UpdateProfileRequestDTO request = new UpdateProfileRequestDTO( + "김길동", LocalDate.of(1990, 1, 1), Level.A, + List.of(Keyword.FRIENDSHIP), null); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // when + mockMvc.perform(patch("/api/my/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then + Member updated = memberRepository.findMemberWithProfileById(member.getId()).orElseThrow(); + assertThat(updated.getMemberName()).isEqualTo("김길동"); + assertThat(updated.getProfileImg()).isNull(); + } + + @Test + @DisplayName("200 - 기존 이미지가 있고 imgKey가 다르면 이미지가 변경된다") + void 기존_이미지가_있고_imgKey가_다르면_이미지가_변경된다() throws Exception { + // given - 기존 프로필 이미지 설정 + ProfileImg existingImg = ProfileImg.builder() + .member(member).imgKey("profile/old-key.jpg").build(); + member.updateProfileImg(existingImg); + memberRepository.save(member); + + UpdateProfileRequestDTO request = new UpdateProfileRequestDTO( + "김길동", LocalDate.of(1990, 1, 1), Level.A, + List.of(Keyword.FRIENDSHIP), "profile/new-key.jpg"); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // when + mockMvc.perform(patch("/api/my/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then + Member updated = memberRepository.findMemberWithProfileById(member.getId()).orElseThrow(); + assertThat(updated.getProfileImg().getImgKey()).isEqualTo("profile/new-key.jpg"); + } + + @Test + @DisplayName("200 - DIRECT 채팅방 상대방의 displayName이 업데이트된다") + void DIRECT_채팅방_상대방의_displayName이_업데이트된다() throws Exception { + // given + Member counterPart = memberRepository.save( + MemberFixture.createMember("상대방", Gender.FEMALE, Level.B, 2001L)); + + ChatRoom directRoom = ChatRoom.createDirectChatRoom(); + directRoom.addChatRoomMember( + ChatFixture.createJoinedMember(directRoom, member, "홍길동")); + directRoom.addChatRoomMember( + ChatFixture.createJoinedMember(directRoom, counterPart, "홍길동")); + chatRoomRepository.save(directRoom); + + UpdateProfileRequestDTO request = new UpdateProfileRequestDTO( + "김길동", LocalDate.of(1990, 1, 1), Level.A, + List.of(Keyword.FRIENDSHIP), null); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // when + mockMvc.perform(patch("/api/my/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then - 상대방의 ChatRoomMember displayName이 업데이트되었는지 검증 + ChatRoomMember updatedCounterPart = chatRoomMemberRepository + .findByChatRoomIdAndMemberId(directRoom.getId(), counterPart.getId()) + .orElseThrow(); + assertThat(updatedCounterPart.getDisplayName()).isEqualTo("김길동"); + } + + @Test + @DisplayName("200 - PARTY 채팅방의 displayName은 변경되지 않는다") + void PARTY_채팅방의_displayName은_변경되지_않는다() throws Exception { + // given + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); + + ChatRoom partyRoom = ChatRoom.createPartyChatRoom(party); + partyRoom.addChatRoomMember( + ChatFixture.createJoinedMember(partyRoom, member, "홍길동")); + chatRoomRepository.save(partyRoom); + + UpdateProfileRequestDTO request = new UpdateProfileRequestDTO( + "김길동", LocalDate.of(1990, 1, 1), Level.A, + List.of(Keyword.FRIENDSHIP), null); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // when + mockMvc.perform(patch("/api/my/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // then - PARTY 채팅방의 ChatRoomMember displayName은 변경되지 않아야 한다 + ChatRoomMember partyChatMember = chatRoomMemberRepository + .findByChatRoomIdAndMemberId(partyRoom.getId(), member.getId()) + .orElseThrow(); + assertThat(partyChatMember.getDisplayName()).isEqualTo("홍길동"); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 회원이면 MEMBER_NOT_FOUND 에러를 반환한다") + void 존재하지_않는_회원이면_MEMBER_NOT_FOUND_에러를_반환한다() throws Exception { + UpdateProfileRequestDTO request = new UpdateProfileRequestDTO( + "김길동", LocalDate.of(1990, 1, 1), Level.A, + List.of(Keyword.FRIENDSHIP), null); + + SecurityContextHelper.setAuthentication(999L, "없는회원"); + + mockMvc.perform(patch("/api/my/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } @Nested @DisplayName("POST /api/my/profile/locations - 주소 추가") diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index c197bc16a..290c391a4 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -9,7 +9,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.support.fixture.ChatFixture; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; @@ -204,8 +206,6 @@ class Success { // given given(memberRepository.findMemberWithProfileById(normalMember.getId())) .willReturn(Optional.of(normalMember)); - given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) - .willReturn(List.of()); // when memberCommandService.updateProfile(requestWithoutImg, normalMember.getId()); @@ -223,8 +223,6 @@ class Success { // given given(memberRepository.findMemberWithProfileById(normalMember.getId())) .willReturn(Optional.of(normalMember)); - given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) - .willReturn(List.of()); // when memberCommandService.updateProfile(requestWithImg, normalMember.getId()); @@ -246,8 +244,6 @@ class Success { given(memberRepository.findMemberWithProfileById(normalMember.getId())) .willReturn(Optional.of(normalMember)); - given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) - .willReturn(List.of()); // when memberCommandService.updateProfile(requestWithImg, normalMember.getId()); @@ -272,8 +268,6 @@ class Success { given(memberRepository.findMemberWithProfileById(normalMember.getId())) .willReturn(Optional.of(normalMember)); - given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) - .willReturn(List.of()); // when memberCommandService.updateProfile(sameImgRequest, normalMember.getId()); @@ -288,8 +282,6 @@ class Success { // given given(memberRepository.findMemberWithProfileById(normalMember.getId())) .willReturn(Optional.of(normalMember)); - given(chatRoomMemberRepository.findAllByMemberId(normalMember.getId())) - .willReturn(List.of()); // when memberCommandService.updateProfile(requestWithImg, normalMember.getId()); @@ -299,6 +291,24 @@ class Success { then(memberKeywordRepository).should().saveAll(any()); assertThat(normalMember.getKeywords()).hasSize(requestWithImg.keywords().size()); } + + @Test + @DisplayName("프로필_수정_시_DIRECT_채팅방_상대방의_displayName이_업데이트된다") + void 프로필_수정_시_DIRECT_채팅방_상대방의_displayName이_업데이트된다() { + // given + ChatRoomMember counterPartCrm = ChatFixture.createChatRoomMemberWithDisplayName("홍길동"); + + given(memberRepository.findMemberWithProfileById(normalMember.getId())) + .willReturn(Optional.of(normalMember)); + given(chatRoomMemberRepository.findDirectChatCounterParts(normalMember.getId())) + .willReturn(List.of(counterPartCrm)); + + // when + memberCommandService.updateProfile(requestWithoutImg, normalMember.getId()); + + // then + assertThat(counterPartCrm.getDisplayName()).isEqualTo(requestWithoutImg.memberName()); + } } @Nested diff --git a/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java index bac1a0404..e5a1a97ec 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ChatFixture.java @@ -31,6 +31,16 @@ public static ChatRoomMember createJoinedMember(ChatRoom chatRoom, Member member return ChatRoomMember.create(chatRoom, member); } + public static ChatRoomMember createJoinedMember(ChatRoom chatRoom, Member member, String displayName) { + return ChatRoomMember.createJoined(chatRoom, member, displayName); + } + + public static ChatRoomMember createChatRoomMemberWithDisplayName(String displayName) { + return ChatRoomMember.builder() + .displayName(displayName) + .build(); + } + public static ChatRoomMember createJoinedMemberWithLastRead(ChatRoom chatRoom, Member member, Long lastReadMessageId) { return ChatRoomMember.builder() .chatRoom(chatRoom) From 5af1ffe684722643466a26621a1a309c0d414af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:29:42 +0900 Subject: [PATCH 14/20] =?UTF-8?q?[feat/#546]=20Flyway=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: flyway설정파일 추가 * chore: flyway 설정 추가 * chore: test에서도 flyway설정이 되도록 수정 --- build.gradle | 4 + src/main/resources/application-local.yml | 2 +- src/main/resources/application-staging.yml | 2 +- src/main/resources/application.yml | 10 +- .../V2026.03.23.00.00__init_schema.sql | 481 ++++++++++++++++++ .../resources/application-integrationtest.yml | 9 +- src/test/resources/application.yml | 3 + 7 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V2026.03.23.00.00__init_schema.sql diff --git a/build.gradle b/build.gradle index 563ba82de..97dfd314c 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,10 @@ dependencies { // firebase implementation 'com.google.firebase:firebase-admin:9.7.1' + + // flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' } tasks.named('test') { diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6832072a9..01b1a8b45 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,7 +10,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate firebase: service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY} diff --git a/src/main/resources/application-staging.yml b/src/main/resources/application-staging.yml index e07bbae70..c6e986d82 100644 --- a/src/main/resources/application-staging.yml +++ b/src/main/resources/application-staging.yml @@ -4,7 +4,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate data: redis: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8c3405618..23feadeab 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,10 +24,18 @@ spring: init: mode: never + flyway: + enabled: true + locations: classpath:db/migration + encoding: UTF-8 + baseline-version: 2026.03.23.00.00 + baseline-on-migrate: true + out-of-order: false + jpa: show_sql: false hibernate: - ddl-auto: update + ddl-auto: validate properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect diff --git a/src/main/resources/db/migration/V2026.03.23.00.00__init_schema.sql b/src/main/resources/db/migration/V2026.03.23.00.00__init_schema.sql new file mode 100644 index 000000000..5ce2a1e57 --- /dev/null +++ b/src/main/resources/db/migration/V2026.03.23.00.00__init_schema.sql @@ -0,0 +1,481 @@ +-- ===================================================== +-- V1__init_schema.sql +-- Cockple 초기 스키마 (엔티티 기반) +-- ===================================================== + +CREATE TABLE IF NOT EXISTS terms +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + content VARCHAR(255) NOT NULL, + version VARCHAR(255) NOT NULL, + effective_date DATE NOT NULL, + in_active BIT NOT NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_addr +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + addr1 VARCHAR(255) NOT NULL, + addr2 VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_addr1_addr2 UNIQUE (addr1, addr2) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS member +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_name VARCHAR(255), + gender VARCHAR(255), + birth DATE, + level VARCHAR(255), + nickname VARCHAR(255), + is_active VARCHAR(255) DEFAULT 'ACTIVE', + refresh_token VARCHAR(255), + social_id BIGINT NOT NULL, + fcm_token VARCHAR(255), + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + party_name VARCHAR(255) NOT NULL, + party_addr_id BIGINT, + party_type VARCHAR(255) NOT NULL, + owner_id BIGINT NOT NULL, + min_birth_year INTEGER NOT NULL, + max_birth_year INTEGER NOT NULL, + price INTEGER NOT NULL, + join_price INTEGER NOT NULL, + designated_cock VARCHAR(255) NOT NULL, + exercise_count INTEGER NOT NULL DEFAULT 0, + content VARCHAR(255), + activity_time VARCHAR(255) NOT NULL, + status VARCHAR(255), + PRIMARY KEY (id), + CONSTRAINT fk_party_party_addr FOREIGN KEY (party_addr_id) REFERENCES party_addr (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exercise_addr +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + addr1 VARCHAR(255) NOT NULL, + addr2 VARCHAR(255) NOT NULL, + street_addr VARCHAR(255) NOT NULL, + building_name VARCHAR(255) NOT NULL, + latitude DOUBLE NOT NULL, + longitude DOUBLE NOT NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exercise +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + addr_id BIGINT, + party_id BIGINT, + date DATE NOT NULL, + start_time TIME(6) NOT NULL, + end_time TIME(6), + max_capacity INTEGER NOT NULL, + party_guest_accept BIT NOT NULL, + outside_guest_accept BIT NOT NULL, + notice VARCHAR(255), + PRIMARY KEY (id), + CONSTRAINT fk_exercise_exercise_addr FOREIGN KEY (addr_id) REFERENCES exercise_addr (id), + CONSTRAINT fk_exercise_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_active_day +( + id BIGINT NOT NULL AUTO_INCREMENT, + party_id BIGINT, + active_day VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_party_active_day_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_img +( + id BIGINT NOT NULL AUTO_INCREMENT, + party_id BIGINT, + img_key VARCHAR(255), + PRIMARY KEY (id), + CONSTRAINT fk_party_img_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_keyword +( + id BIGINT NOT NULL AUTO_INCREMENT, + party_id BIGINT, + keyword VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_party_keyword_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_level +( + id BIGINT NOT NULL AUTO_INCREMENT, + party_id BIGINT, + gender VARCHAR(255) NOT NULL, + level VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_party_level_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS member_addr +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + addr1 VARCHAR(255) NOT NULL, + addr2 VARCHAR(255) NOT NULL, + addr3 VARCHAR(255) NOT NULL, + street_addr VARCHAR(255) NOT NULL, + building_name VARCHAR(255), + latitude DOUBLE NOT NULL, + longitude DOUBLE NOT NULL, + is_main BIT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_member_addr_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS member_exercise +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + exercise_id BIGINT, + exercise_member_ship_status VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_member_exercise_member FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_member_exercise_exercise FOREIGN KEY (exercise_id) REFERENCES exercise (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS member_keyword +( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT, + keyword VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_member_keyword_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS member_party +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + party_id BIGINT, + member_id BIGINT, + role VARCHAR(255) NOT NULL, + joined_at DATETIME(6) NOT NULL, + status VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_member_party_party FOREIGN KEY (party_id) REFERENCES party (id), + CONSTRAINT fk_member_party_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS member_terms +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + terms_id BIGINT, + agree BIT NOT NULL, + agreed_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_member_terms_member FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_member_terms_terms FOREIGN KEY (terms_id) REFERENCES terms (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS profile_img +( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT, + img_key VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_profile_img_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS contest +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + contest_name VARCHAR(255) NOT NULL, + medal_type VARCHAR(255) NOT NULL, + date DATE, + type VARCHAR(255) NOT NULL, + level VARCHAR(255) NOT NULL, + content VARCHAR(255), + content_is_open BIT NOT NULL, + video_is_open BIT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_contest_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS contest_img +( + id BIGINT NOT NULL AUTO_INCREMENT, + contest_id BIGINT, + img_key VARCHAR(255) NOT NULL, + img_order INTEGER NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_contest_img_contest FOREIGN KEY (contest_id) REFERENCES contest (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS contest_video +( + id BIGINT NOT NULL AUTO_INCREMENT, + contest_id BIGINT, + video_url VARCHAR(255) NOT NULL, + video_order INTEGER NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_contest_video_contest FOREIGN KEY (contest_id) REFERENCES contest (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notification +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + party_id BIGINT NOT NULL, + title VARCHAR(255), + content VARCHAR(255) NOT NULL, + type VARCHAR(255) NOT NULL, + is_read BIT NOT NULL, + image_key VARCHAR(255), + data TEXT, + PRIMARY KEY (id), + CONSTRAINT fk_notification_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS exercise_bookmark +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + exercise_id BIGINT, + PRIMARY KEY (id), + CONSTRAINT fk_exercise_bookmark_member FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_exercise_bookmark_exercise FOREIGN KEY (exercise_id) REFERENCES exercise (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_bookmark +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + party_id BIGINT, + order_type VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_party_bookmark_member FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_party_bookmark_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_invitation +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + party_id BIGINT, + inviter_id BIGINT, + invitee_id BIGINT, + status VARCHAR(255), + PRIMARY KEY (id), + CONSTRAINT fk_party_invitation_party FOREIGN KEY (party_id) REFERENCES party (id), + CONSTRAINT fk_party_invitation_inviter FOREIGN KEY (inviter_id) REFERENCES member (id), + CONSTRAINT fk_party_invitation_invitee FOREIGN KEY (invitee_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS party_join_request +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + member_id BIGINT, + party_id BIGINT, + status VARCHAR(255), + PRIMARY KEY (id), + CONSTRAINT fk_party_join_request_member FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_party_join_request_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS guest +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + exercise_id BIGINT, + guest_name VARCHAR(255) NOT NULL, + gender VARCHAR(255) NOT NULL, + level VARCHAR(255) NOT NULL, + inviter_id BIGINT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_guest_exercise FOREIGN KEY (exercise_id) REFERENCES exercise (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS chat_room +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + party_id BIGINT, + type VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_chat_room_party FOREIGN KEY (party_id) REFERENCES party (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS chat_room_member +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + chat_room_id BIGINT, + member_id BIGINT, + display_name VARCHAR(255), + last_read_message_id BIGINT, + status VARCHAR(255) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_chat_room_member_chat_room FOREIGN KEY (chat_room_id) REFERENCES chat_room (id), + CONSTRAINT fk_chat_room_member_member FOREIGN KEY (member_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS chat_message +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + chat_room_id BIGINT, + sender_id BIGINT, + content TEXT, + type VARCHAR(255) NOT NULL, + is_deleted BIT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_chat_message_chat_room FOREIGN KEY (chat_room_id) REFERENCES chat_room (id), + CONSTRAINT fk_chat_message_sender FOREIGN KEY (sender_id) REFERENCES member (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS chat_message_file +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + chat_message_id BIGINT, + file_key VARCHAR(255) NOT NULL, + file_order INTEGER NOT NULL, + original_file_name VARCHAR(255) NOT NULL, + file_size BIGINT, + file_type VARCHAR(255), + is_emoji BIT NOT NULL DEFAULT FALSE, + PRIMARY KEY (id), + CONSTRAINT fk_chat_message_file_chat_message FOREIGN KEY (chat_message_id) REFERENCES chat_message (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS message_read_status +( + id BIGINT NOT NULL AUTO_INCREMENT, + chat_message_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + chat_room_id BIGINT NOT NULL, + is_read BIT NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_message_read_status_message_member UNIQUE (chat_message_id, member_id), + INDEX idx_message_read_message (chat_message_id), + INDEX idx_message_read_member (member_id), + INDEX idx_message_read_chatroom_member (chat_room_id, member_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS download_token +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + token VARCHAR(255) NOT NULL UNIQUE, + file_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + expires_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index 171e20b30..4236bc3f9 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -5,9 +5,16 @@ spring: host: localhost port: 6379 + flyway: + enabled: true + locations: classpath:db/migration + encoding: UTF-8 + baseline-on-migrate: false + out-of-order: false + jpa: hibernate: - ddl-auto: create + ddl-auto: none properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 0507865ab..570edddd1 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -6,6 +6,9 @@ spring: username: sa password: + flyway: + enabled: false + sql: init: mode: never From 32460d3b3388d927aeaaaa907ef8d8e3eb1a7ad9 Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:09:15 +0900 Subject: [PATCH 15/20] =?UTF-8?q?[fix/#552]=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=A9=A4=EB=B2=84=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=B4=EC=A0=95=20?= =?UTF-8?q?(#553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: MySQL 메모리가 database 두 개를 커버하기에는 부족하므로 1gb로 증가 * fix: 모임 채팅방 멤버 누락 마이그레이션 sql 쿼리 작성 --- docker-compose.yml | 6 ++-- ...ckfill_missing_party_chat_room_members.sql | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V2026.03.24.16.00__backfill_missing_party_chat_room_members.sql diff --git a/docker-compose.yml b/docker-compose.yml index d7f0df5ec..2a883bb6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - - --innodb-buffer-pool-size=256M + - --innodb-buffer-pool-size=512M volumes: - mysql-data:/var/lib/mysql - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro @@ -22,8 +22,8 @@ services: interval: 10s timeout: 5s retries: 5 - mem_limit: 512m - memswap_limit: 768m + mem_limit: 1g + memswap_limit: 1536m redis: image: redis:7-alpine diff --git a/src/main/resources/db/migration/V2026.03.24.16.00__backfill_missing_party_chat_room_members.sql b/src/main/resources/db/migration/V2026.03.24.16.00__backfill_missing_party_chat_room_members.sql new file mode 100644 index 000000000..cbc64e256 --- /dev/null +++ b/src/main/resources/db/migration/V2026.03.24.16.00__backfill_missing_party_chat_room_members.sql @@ -0,0 +1,28 @@ +INSERT INTO chat_room_member ( + created_at, + updated_at, + chat_room_id, + member_id, + display_name, + last_read_message_id, + status +) +SELECT + NOW(6), + NOW(6), + cr.id, + mp.member_id, + NULL, + NULL, + 'JOINED' +FROM member_party mp +JOIN party p + ON p.id = mp.party_id +JOIN chat_room cr + ON cr.party_id = mp.party_id +LEFT JOIN chat_room_member crm + ON crm.chat_room_id = cr.id + AND crm.member_id = mp.member_id +WHERE mp.status = 'ACTIVE' + AND p.status = 'ACTIVE' + AND crm.id IS NULL; From c09f030ce7b14b76346f3aaa1e8ec36a2285f1ed Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:09:27 +0900 Subject: [PATCH 16/20] =?UTF-8?q?docs:=20=EC=98=A4=ED=94=88=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?.md=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/AGENTS.md | 28 +++++++ AGENTS.md | 84 +++++++++++++++++++ scripts/AGENTS.md | 28 +++++++ .../java/umc/cockple/demo/domain/AGENTS.md | 32 +++++++ .../umc/cockple/demo/domain/chat/AGENTS.md | 27 ++++++ .../cockple/demo/domain/exercise/AGENTS.md | 27 ++++++ .../java/umc/cockple/demo/global/AGENTS.md | 29 +++++++ .../umc/cockple/demo/global/config/AGENTS.md | 25 ++++++ src/test/java/umc/cockple/demo/AGENTS.md | 27 ++++++ terraform/AGENTS.md | 31 +++++++ 10 files changed, 338 insertions(+) create mode 100644 .github/workflows/AGENTS.md create mode 100644 AGENTS.md create mode 100644 scripts/AGENTS.md create mode 100644 src/main/java/umc/cockple/demo/domain/AGENTS.md create mode 100644 src/main/java/umc/cockple/demo/domain/chat/AGENTS.md create mode 100644 src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md create mode 100644 src/main/java/umc/cockple/demo/global/AGENTS.md create mode 100644 src/main/java/umc/cockple/demo/global/config/AGENTS.md create mode 100644 src/test/java/umc/cockple/demo/AGENTS.md create mode 100644 terraform/AGENTS.md diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md new file mode 100644 index 000000000..ccd7b9793 --- /dev/null +++ b/.github/workflows/AGENTS.md @@ -0,0 +1,28 @@ +# WORKFLOWS GUIDE + +Apply root `AGENTS.md` first. This file only applies to `.github/workflows/*` edits. + +## OVERVIEW +This directory drives CI on pull requests and CD on pushes to `develop` and `main`. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| PR build/test | `ci.yml` | writes `application.yml`, creates Firebase key, runs `./gradlew build` | +| Image build + deploy | `cd.yml` | builds on push, tags by branch, then copies compose/nginx/scripts to server | + +## CONVENTIONS +- `develop` and `main` are the only workflow branches here. +- `ci.yml` chooses `APPLICATION` vs `APPLICATION_STAGING` by `github.base_ref`. +- `cd.yml` chooses Docker tag `latest` for `main`, `staging` otherwise. +- Deploy step assumes `/home/ubuntu/cockple` and hands off to `scripts/deploy.sh`. + +## ANTI-PATTERNS +- Do not hardcode secrets into workflow YAML. +- Do not change copied deploy assets in `cd.yml` without matching script/compose expectations. +- Do not make `main` and `develop` diverge silently; branch-specific behavior is intentionally small and explicit. + +## COMMANDS +```bash +./gradlew.bat build +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..833ffa033 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-03-23 20:04 KST +**Commit:** 5af1ffe6 +**Branch:** develop + +## OVERVIEW +Cockple_BE is a single-module Spring Boot 3.5.9 backend for the Cockple badminton platform. Core stack: Java 17, JPA + QueryDSL, MySQL, Redis, JWT/Kakao OAuth, WebSocket chat, Firebase FCM, Flyway, Docker Compose, and Terraform-managed GCP/Cloudflare infra. + +Nearest `AGENTS.md` wins. Read this file first, then the closest child file for the area you are changing. + +## STRUCTURE +```text +./ +├── .github/workflows/ # CI/CD entrypoint +├── scripts/ # deploy and SSH tunnel helpers +├── terraform/ # GCP + Cloudflare infra +├── nginx/ # reverse proxy for prod/staging apps +├── src/main/java/umc/cockple/demo/domain/ # business slices +├── src/main/java/umc/cockple/demo/global/ # cross-cutting infra +├── src/main/resources/ # Spring profiles + Flyway +├── src/main/generated/ # generated QueryDSL Q-types +└── src/test/java/umc/cockple/demo/ # integration/service tests + fixtures +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| App bootstrap | `src/main/java/umc/cockple/demo/Application.java` | Enables JPA auditing and caching | +| Runtime config | `src/main/resources/application*.yml` | `local` is default; `staging` and `prod` override DB/Redis | +| Security/websocket ingress | `src/main/java/umc/cockple/demo/global/config/` | JWT, CORS, WebSocket handler registration | +| Business APIs | `src/main/java/umc/cockple/demo/domain/` | Vertical slices by feature | +| Exercise hotspot | `src/main/java/umc/cockple/demo/domain/exercise/` | Largest converter/query/test surface | +| Chat hotspot | `src/main/java/umc/cockple/demo/domain/chat/` | REST + WebSocket + cache/event flow | +| Shared infra | `src/main/java/umc/cockple/demo/global/` | response, exception, jwt, oauth2, config | +| Test conventions | `src/test/java/umc/cockple/demo/` | shared fixtures + integration base live here | +| Local stack | `docker-compose.yml`, `nginx/`, `scripts/` | prod/staging services share one compose file | +| CI/CD | `.github/workflows/` | PR builds on `develop`/`main`; push deploys by branch | +| Infra changes | `terraform/` | GCP compute/network/storage + Cloudflare | + +## CONVENTIONS +- Domain code is organized as feature slices, not horizontal layers across the repo. +- Controllers usually return `BaseResponse` or `ResponseEntity>`. +- Error codes are per-domain enums and use a documented `1xx/2xx/3xx/4xx` meaning split. +- `application.yml` enforces `ddl-auto: validate`; schema changes belong in Flyway under `src/main/resources/db/migration/`. +- QueryDSL generated sources are wired through Gradle; handwritten code lives under `src/main/java`, generated code under `src/main/generated` or `build/generated/...`. +- Tests split into integration (`MockMvc` + Testcontainers profile) and service/unit (`MockitoExtension`) styles. + +## ANTI-PATTERNS (THIS PROJECT) +- Do not edit generated QueryDSL Q-types. +- Do not widen security whitelist or CORS origins casually; both are explicit in `SecurityConfig` and `WebSocketConfig`. +- Do not rely on JPA auto-DDL for schema work; startup uses validation only. +- Do not scatter test fixtures inside feature test packages; shared fixtures already live under `src/test/java/umc/cockple/demo/support/fixture/`. +- Do not assume everything under `global/` is generic; JWT/OAuth code is coupled to member/auth flows. + +## UNIQUE STYLES +- `domain/chat` has extra realtime sublayers: `handler/`, `interceptor/`, `events/`, `service/websocket/`. +- `domain/exercise` is the densest slice: large converter, query service, command internals, and the biggest integration tests. +- `domain/party` and `domain/notification` use events/notification wiring more than simpler slices like `bookmark` or `terms`. + +## COMMANDS +```bash +./gradlew.bat build +./gradlew.bat test +./gradlew.bat bootRun +docker compose config --services +bash scripts/tunnel.sh +``` + +## CHILD GUIDES +- `.github/workflows/AGENTS.md` +- `scripts/AGENTS.md` +- `terraform/AGENTS.md` +- `src/main/java/umc/cockple/demo/domain/AGENTS.md` +- `src/main/java/umc/cockple/demo/domain/chat/AGENTS.md` +- `src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md` +- `src/main/java/umc/cockple/demo/global/AGENTS.md` +- `src/main/java/umc/cockple/demo/global/config/AGENTS.md` +- `src/test/java/umc/cockple/demo/AGENTS.md` + +## NOTES +- CI writes `src/main/resources/application.yml` from GitHub secrets before building. +- CD copies only `docker-compose.yml`, `init-db.sql`, `nginx/`, and `scripts/` to the server; deploy behavior lives in both workflow and shell script. +- Local profile expects MySQL on `3307` and Redis on `6380`; `scripts/tunnel.sh` exposes those ports through SSH forwarding. diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 000000000..edc6ceeed --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,28 @@ +# SCRIPTS GUIDE + +Apply root `AGENTS.md` first. This file only applies to `scripts/*`. + +## OVERVIEW +Shell scripts handle server deployment and local SSH tunneling into the remote stack. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Branch-aware deploy | `deploy.sh` | writes `.env`, pulls image tag, restarts one app service | +| Remote DB/Redis access | `tunnel.sh` | forwards MySQL to `3307`, Redis to `6380` | + +## CONVENTIONS +- `deploy.sh` treats `main` as `cockple-app:latest`; any other branch path becomes `cockple-app-staging:staging`. +- Deploy always starts `mysql`, `redis`, and `nginx` before replacing the app container. +- Health checks are part of the script, not just Docker Compose. + +## ANTI-PATTERNS +- Do not change forwarded local ports without matching `application-local.yml`. +- Do not add new required env vars in scripts without updating workflow `envs` and compose. +- Do not bypass the health-check loop when changing deploy behavior. + +## COMMANDS +```bash +bash scripts/deploy.sh +bash scripts/tunnel.sh +``` diff --git a/src/main/java/umc/cockple/demo/domain/AGENTS.md b/src/main/java/umc/cockple/demo/domain/AGENTS.md new file mode 100644 index 000000000..50d2ca9b7 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/AGENTS.md @@ -0,0 +1,32 @@ +# DOMAIN GUIDE + +Apply root `AGENTS.md` first. This file covers feature slices under `domain/`. + +## OVERVIEW +Business logic is organized by feature slice, usually with `controller`, `converter`, `domain`, `dto`, `enums`, `exception`, `repository`, and `service` subpackages. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Member/account flows | `member/` | auth-adjacent domain code | +| Party lifecycle | `party/` | joins, invitations, roles, events | +| Exercise lifecycle | `exercise/` | largest slice; read child guide | +| Chat realtime flows | `chat/` | read child guide | +| Notifications | `notification/` | FCM-backed notifications | +| Simpler slices | `bookmark/`, `contest/`, `file/`, `terms/` | mostly standard pattern | + +## CONVENTIONS +- Domain-specific errors live in each slice’s `exception/*ErrorCode.java`. +- Controllers expose `/api/...` endpoints and use global response wrappers. +- Shared enums/base entities come from `global/`; slice-specific rules stay here. +- `party/` and `chat/` add `events/`; `party/` also has `utils/`. +- QueryDSL generated code mirrors entity packages elsewhere; do not mix handwritten logic into generated folders. + +## ANTI-PATTERNS +- Do not move business rules into `global/` just because multiple slices depend on them. +- Do not bypass slice error codes with ad hoc strings or generic exceptions. +- Do not edit generated Q-types to “fix” repository behavior. + +## CHILD GUIDES +- `chat/AGENTS.md` +- `exercise/AGENTS.md` diff --git a/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md b/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md new file mode 100644 index 000000000..cf075e565 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/AGENTS.md @@ -0,0 +1,27 @@ +# CHAT GUIDE + +Apply parent guides first. This file only covers `domain/chat/`. + +## OVERVIEW +Chat mixes REST queries with WebSocket transport, Redis-backed subscription/cache behavior, and domain events. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| WebSocket ingress | `handler/ChatWebSocketHandler.java` | handles connect/message/close/error | +| Auth for sockets | `interceptor/` | JWT auth is enforced before handler logic | +| Realtime services | `service/websocket/` | subscription, room list cache, message fanout | +| Read/query flows | `service/ChatQueryServiceImpl.java` | room lists, unread counts, history | +| Events | `events/` | send/subscription events bridge transport and async handlers | +| DTO conversion | `converter/ChatConverter.java` | shapes REST/socket payloads | + +## CONVENTIONS +- `WebSocketConfig` registers `/ws/chats` and wires the JWT interceptor. +- Request `type()` drives socket branching: send, subscribe, unsubscribe, and chat-list variants. +- Party chat and direct chat share the slice but differ in display-name/image/read-status logic. +- Room list freshness depends on `service/websocket/ChatRoomListCacheService`. + +## ANTI-PATTERNS +- Do not treat chat as HTTP-only; transport, cache, and event flow are part of the slice. +- Do not bypass membership/access validation before room or message operations. +- Do not mix socket session bookkeeping into controllers or converters. diff --git a/src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md b/src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md new file mode 100644 index 000000000..90e916684 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/exercise/AGENTS.md @@ -0,0 +1,27 @@ +# EXERCISE GUIDE + +Apply parent guides first. This file only covers `domain/exercise/`. + +## OVERVIEW +Exercise is the densest feature slice: scheduling, guests, participation, waiting lists, recommendations, map/calendar queries, and the largest integration/service tests. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Read-heavy hotspot | `service/ExerciseQueryService.java` | calendar/detail/recommendation/building/map queries | +| Command internals | `service/command/internal/` | guest, lifecycle, participation subflows | +| DTO mapping hotspot | `converter/ExerciseConverter.java` | very large conversion surface | +| HTTP surface | `controller/ExerciseController.java` | CRUD + guests + calendars | +| Error rules | `exception/ExerciseErrorCode.java` | started/past-time/permission constraints | +| Integration coverage | `src/test/java/umc/cockple/demo/domain/exercise/` | biggest test package in repo | + +## CONVENTIONS +- Time/date/location validation is centralized and reused through service methods and error codes. +- Participation logic distinguishes confirmed participants from waiting members/guests. +- Guest invitation rules depend on both party membership and exercise flags. +- Converter growth is already high; new mapping code should stay tightly scoped to one flow. + +## ANTI-PATTERNS +- Do not bypass `EXERCISE4xx` guardrails for past/start-state checks. +- Do not duplicate participant/waiting-list logic in controllers or tests. +- Do not spread unrelated conversions into `ExerciseConverter` without checking for an existing narrower path first. diff --git a/src/main/java/umc/cockple/demo/global/AGENTS.md b/src/main/java/umc/cockple/demo/global/AGENTS.md new file mode 100644 index 000000000..6df4c7732 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/AGENTS.md @@ -0,0 +1,29 @@ +# GLOBAL GUIDE + +Apply root `AGENTS.md` first. This file covers `global/` cross-cutting code. + +## OVERVIEW +`global/` holds shared framework glue: response/exception handling, infra config, security, JWT, OAuth, shared enums, and the common base entity. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Bootstrap/config wiring | `config/` | see child guide | +| Response wrapper | `response/` | `BaseResponse`, code DTOs, success/error codes | +| Exception handling | `exception/` | global handler + shared exception base | +| Security/JWT | `security/`, `jwt/` | auth filter + token creation/parsing | +| OAuth | `oauth2/` | Kakao-specific login flow | +| Shared types | `common/BaseEntity.java`, `enums/` | audit fields and shared enums | + +## CONVENTIONS +- API success/error formatting is centralized here; slices should plug into it rather than invent local wrappers. +- `jwt/` and `oauth2/` are cross-cutting in placement but still coupled to member/auth flows. +- `common/BaseEntity` and shared enums are the stable lowest-level shared types. + +## ANTI-PATTERNS +- Do not put feature-specific business rules here unless they are truly shared. +- Do not assume JWT/OAuth classes are provider-agnostic; this repo is Kakao-specific. +- Do not bypass shared response/error abstractions from controllers. + +## CHILD GUIDES +- `config/AGENTS.md` diff --git a/src/main/java/umc/cockple/demo/global/config/AGENTS.md b/src/main/java/umc/cockple/demo/global/config/AGENTS.md new file mode 100644 index 000000000..a2bb68b13 --- /dev/null +++ b/src/main/java/umc/cockple/demo/global/config/AGENTS.md @@ -0,0 +1,25 @@ +# CONFIG GUIDE + +Apply parent guides first. This file only covers `global/config/`. + +## OVERVIEW +This package is the runtime bootstrap surface for security, WebSocket, Redis/cache, Firebase, Swagger, QueryDSL, async work, and external storage wiring. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Security whitelist + CORS | `SecurityConfig.java` | explicit public endpoints and allowed origins | +| WebSocket bootstrap | `WebSocketConfig.java` | binds `/ws/chats` to handler + JWT interceptor | +| Redis/cache serialization | `RedisConfig.java` | connection factory, templates, cache manager | +| Firebase init | `FirebaseConfig.java` | disabled for `integrationtest` profile | + +## CONVENTIONS +- Runtime values belong in `application*.yml`; config classes wire beans around those values. +- `SecurityConfig` and `WebSocketConfig` both carry explicit frontend origin lists. +- Firebase is suppressed during integration tests and mocked from test config. +- Redis serialization uses a permissive polymorphic JSON serializer; cache/template behavior lives here, not in slices. + +## ANTI-PATTERNS +- Do not widen public endpoints or origins without understanding auth and deployment impact. +- Do not embed feature logic in config beans. +- Do not duplicate profile gating from `application*.yml` inside random services. diff --git a/src/test/java/umc/cockple/demo/AGENTS.md b/src/test/java/umc/cockple/demo/AGENTS.md new file mode 100644 index 000000000..6d0ae34f5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/AGENTS.md @@ -0,0 +1,27 @@ +# TEST GUIDE + +Apply root `AGENTS.md` first. This file covers `src/test/java/umc/cockple/demo/`. + +## OVERVIEW +Tests mirror production domains and split into integration tests with Testcontainers/MockMvc and service tests with Mockito. Shared support code lives under `support/`. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Integration base | `support/IntegrationTestBase.java` | `@SpringBootTest`, `MockMvc`, `integrationtest` profile | +| Testcontainers + mocks | `support/IntegrationTestConfig.java` | MySQL, Redis, mocked FirebaseMessaging | +| Auth helpers | `support/SecurityContextHelper.java` | sets and clears security context | +| Shared fixtures | `support/fixture/` | static factories for member/party/exercise/chat/etc. | +| Feature integration tests | `domain/*/integration/` | HTTP-level assertions and manual cleanup | +| Feature service tests | `domain/*/service/` | Mockito-based isolated logic tests | + +## CONVENTIONS +- Integration tests extend `IntegrationTestBase` and usually clear repositories in `@AfterEach`. +- Service tests use `@ExtendWith(MockitoExtension.class)` and shared fixtures. +- `application.yml` in test resources uses H2; `application-integrationtest.yml` switches to MySQL/Redis Testcontainers. +- Large exercise/member/chat tests follow the same nested-class style; keep new tests aligned with that structure. + +## ANTI-PATTERNS +- Do not create per-feature fixture factories when an existing shared fixture can be extended. +- Do not mix Mockito-style unit tests and full integration setup in the same class. +- Do not forget `SecurityContextHelper.clearAuthentication()` in integration teardown paths. diff --git a/terraform/AGENTS.md b/terraform/AGENTS.md new file mode 100644 index 000000000..c2e8d7bbf --- /dev/null +++ b/terraform/AGENTS.md @@ -0,0 +1,31 @@ +# TERRAFORM GUIDE + +Apply root `AGENTS.md` first. This file only applies to `terraform/*`. + +## OVERVIEW +Terraform provisions the Cockple GCP network/compute/storage stack and Cloudflare DNS integration. + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Providers | `main.tf` | Google + Cloudflare providers | +| Network/firewall | `network.tf` | VPC, subnet, Cloudflare-only HTTP ingress, open SSH | +| Compute bootstrap | `compute.tf` | VM, static IP, Docker install via startup script | +| Storage/IAM | `storage.tf` | GCS bucket + service account + public read | +| Inputs/outputs | `variables.tf`, `outputs.tf` | secret vars and exported IP/bucket/account | + +## CONVENTIONS +- Region defaults to `asia-northeast3`. +- HTTP ingress is intentionally restricted to Cloudflare IP ranges. +- Bucket CORS is pinned to Cockple prod/staging domains. + +## ANTI-PATTERNS +- Do not weaken firewall or bucket exposure without touching the matching runtime assumptions. +- Do not move secrets out of sensitive variables into plain values. +- Do not duplicate Docker/bootstrap logic here and in scripts without keeping them aligned. + +## COMMANDS +```bash +terraform plan +terraform apply +``` From 7b49c30c4e13de2d1018dfa85fcf1b3e38ecc667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:49:45 +0900 Subject: [PATCH 17/20] =?UTF-8?q?[test/#518]=20Bookmark=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#5?= =?UTF-8?q?56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 찜 통합테스트 테스트코드 작성 * test: 찜 단위테스트 작성 * test: 찜 목록 조회 누락된 부분추가 * test: order도 테스트 할 수 있도록 수정 --- .../integration/BookmarkIntegrationTest.java | 514 ++++++++++++++++++ .../service/BookmarkCommandServiceTest.java | 498 +++++++++++++++++ .../service/BookmarkQueryServiceTest.java | 503 +++++++++++++++++ 3 files changed, 1515 insertions(+) create mode 100644 src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java new file mode 100644 index 000000000..984dfb50f --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java @@ -0,0 +1,514 @@ +package umc.cockple.demo.domain.bookmark.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.enums.BookmarkedExerciseOrderType; +import umc.cockple.demo.domain.bookmark.exception.BookmarkErrorCode; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class BookmarkIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired ExerciseBookmarkRepository exerciseBookmarkRepository; + @Autowired PartyBookmarkRepository partyBookmarkRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + + + private Member member; + private Party bookmarkParty; + private Exercise bookmarkExercise; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L)); + + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("경기도", "안산시")); + bookmarkParty = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); + + memberPartyRepository.save(MemberFixture.createMemberParty(bookmarkParty, member, Role.party_MANAGER)); + + bookmarkExercise = exerciseRepository.save(Exercise.builder() + .party(bookmarkParty) + .date(LocalDate.of(2026, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .exerciseAddr(ExerciseAddr.builder() + .addr1("경기도") + .addr2("안산시") + .streetAddr("경기도 안산시 한양대학로 1") + .buildingName("테스트 체육관") + .latitude(37.5) + .longitude(127.0) + .build()) + .build()); + } + + @AfterEach + void tearDown() { + exerciseBookmarkRepository.deleteAll(); + partyBookmarkRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("POST /api/parties/{partyId}/bookmark - 모임 찜하기") + class PartyBookmarkCreate { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 모임을 찜하면 partyBookmarkId를 반환한다") + void partyBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNumber()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 모임이면 에러를 반환한다") + void partyNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 찜한 모임이면 에러를 반환한다") + void alreadyBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + partyBookmarkRepository.save(PartyBookmark.builder() + .party(bookmarkParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_BOOKMARK.getMessage())); + } + + @Test + @DisplayName("400 - 삭제된 모임은 찜할 수 없다") + void partyIsDeleted() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + bookmarkParty.delete(); + partyRepository.save(bookmarkParty); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_IS_DELETED.getMessage())); + } + } + } + + + @Nested + @DisplayName("DELETE /api/parties/{partyId}/bookmark - 모임 찜 해제") + class PartyBookmarkRelease { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 찜한 모임을 해제하면 200 응답을 반환한다") + void releasePartyBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + partyBookmarkRepository.save(PartyBookmark.builder() + .party(bookmarkParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + + mockMvc.perform(delete("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 모임이면 에러를 반환한다") + void partyNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 찜하지 않은 모임을 해제하려 하면 에러를 반환한다") + void notBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getMessage())); + } + } + } + + + @Nested + @DisplayName("POST /api/exercises/{exerciseId}/bookmark - 운동 찜하기") + class ExerciseBookmarkCreate { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 운동을 찜하면 exerciseBookmarkId를 반환한다") + void exerciseBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNumber()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 찜한 운동이면 에러를 반환한다") + void alreadyBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(bookmarkExercise) + .build()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_BOOKMARK.getMessage())); + } + } + } + + + @Nested + @DisplayName("DELETE /api/exercises/{exerciseId}/bookmark - 운동 찜 해제") + class ExerciseBookmarkRelease { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 찜한 운동을 해제하면 200 응답을 반환한다") + void releaseExerciseBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(bookmarkExercise) + .build()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 찜하지 않은 운동을 해제하려 하면 에러를 반환한다") + void notBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getMessage())); + } + } + } + + + @Nested + @DisplayName("GET /api/exercises/bookmarks - 찜한 운동 전체 조회") + class GetAllExerciseBookmarks { + + private Exercise newExercise; + + @BeforeEach + void setUp() { + // 먼저 저장 = 오래된 북마크 + memberExerciseRepository.save(MemberFixture.createMemberExercise(member, bookmarkExercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(bookmarkExercise) + .build()); + + // 나중에 저장 = 최신 북마크 + newExercise = exerciseRepository.save(Exercise.builder() + .party(bookmarkParty) + .date(LocalDate.of(2027, 6, 30)) + .startTime(LocalTime.of(14, 0)) + .endTime(LocalTime.of(16, 0)) + .maxCapacity(8) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .exerciseAddr(ExerciseAddr.builder() + .addr1("경기도") + .addr2("안산시") + .streetAddr("경기도 안산시 한양대학로 1") + .buildingName("테스트 체육관") + .latitude(37.5) + .longitude(127.0) + .build()) + .build()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(member, newExercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(newExercise) + .build()); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - LATEST 정렬로 찜한 운동을 전체 조회하면 최신순으로 반환한다") + void getAllExerciseBookmarks_latest_allFields() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/exercises/bookmarks") + .param("orderType", BookmarkedExerciseOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].exerciseId").value(newExercise.getId())) + .andExpect(jsonPath("$.data[1].exerciseId").value(bookmarkExercise.getId())) + .andExpect(jsonPath("$.data[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data[0].streetAddr").value("경기도 안산시 한양대학로 1")) + .andExpect(jsonPath("$.data[0].femaleLevel").isArray()) + .andExpect(jsonPath("$.data[0].maleLevel").isArray()) + .andExpect(jsonPath("$.data[0].date").value("2027-06-30")) + .andExpect(jsonPath("$.data[0].startExerciseTime").value("14:00:00")) + .andExpect(jsonPath("$.data[0].endExerciseTime").value("16:00:00")) + .andExpect(jsonPath("$.data[0].maxMemberCnt").value(8)) + .andExpect(jsonPath("$.data[0].nowMemberCnt").isNumber()) + .andExpect(jsonPath("$.data[0].includeParty").value(true)) + .andExpect(jsonPath("$.data[0].includeExercise").value(true)); + } + + @Test + @DisplayName("200 - EARLIEST 정렬로 찜한 운동을 전체 조회하면 오래된순으로 반환한다") + void getAllExerciseBookmarks_earliest() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/exercises/bookmarks") + .param("orderType", BookmarkedExerciseOrderType.EARLIEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].exerciseId").value(bookmarkExercise.getId())) + .andExpect(jsonPath("$.data[1].exerciseId").value(newExercise.getId())); + } + + @Test + @DisplayName("200 - 찜한 운동이 없으면 빈 리스트를 반환한다") + void noBookmarks_returnsEmptyList() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/exercises/bookmarks") + .param("orderType", BookmarkedExerciseOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + } + } + + // ============================================================ + // GET /api/parties/bookmarks - 찜한 모임 전체 조회 + // ============================================================ + + @Nested + @DisplayName("GET /api/parties/bookmarks - 찜한 모임 전체 조회") + class GetAllPartyBookmarks { + + private Party newParty; + + @BeforeEach + void setUp() { + // 먼저 저장 = 오래된 북마크 + partyBookmarkRepository.save(PartyBookmark.builder() + .party(bookmarkParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + + // 나중에 저장 = 최신 북마크 + PartyAddr newAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + newParty = partyRepository.save(PartyFixture.createParty("새 테스트 모임", member.getId(), newAddr)); + + partyBookmarkRepository.save(PartyBookmark.builder() + .party(newParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - LATEST 정렬로 찜한 모임을 전체 조회하면 최신순으로 반환한다") + void getAllPartyBookmarks_latest_allFields() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].partyId").value(newParty.getId())) + .andExpect(jsonPath("$.data[1].partyId").value(bookmarkParty.getId())) + .andExpect(jsonPath("$.data[1].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data[1].addr1").value("경기도")) + .andExpect(jsonPath("$.data[1].addr2").value("안산시")) + .andExpect(jsonPath("$.data[1].maleLevel").isArray()) + .andExpect(jsonPath("$.data[1].femaleLevel").isArray()) + .andExpect(jsonPath("$.data[1].latestExerciseDate").value("2026-12-31")) + .andExpect(jsonPath("$.data[1].latestExerciseTime").value("MORNING")) + .andExpect(jsonPath("$.data[1].exerciseCnt").isNumber()) + .andExpect(jsonPath("$.data[1].profileImgUrl").value(nullValue())); + } + + @Test + @DisplayName("200 - EXERCISE_COUNT 정렬로 찜한 모임 전체 조회 시 성공한다") + void getAllPartyBookmarks_exerciseCount() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.EXERCISE_COUNT.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))); + } + + @Test + @DisplayName("200 - OLDEST 정렬로 찜한 모임을 전체 조회하면 오래된순으로 반환한다") + void getAllPartyBookmarks_oldest() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.OLDEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].partyId").value(bookmarkParty.getId())) + .andExpect(jsonPath("$.data[1].partyId").value(newParty.getId())); + } + + @Test + @DisplayName("200 - 찜한 모임이 없으면 빈 리스트를 반환한다") + void noBookmarks_returnsEmptyList() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java new file mode 100644 index 000000000..f4ae228d0 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java @@ -0,0 +1,498 @@ +package umc.cockple.demo.domain.bookmark.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.exception.BookmarkErrorCode; +import umc.cockple.demo.domain.bookmark.exception.BookmarkException; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookmarkCommandService") +class BookmarkCommandServiceTest { + + @InjectMocks + private BookmarkCommandService bookmarkCommandService; + + @Mock private PartyBookmarkRepository partyBookmarkRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private MemberRepository memberRepository; + @Mock private PartyRepository partyRepository; + @Mock private ExerciseRepository exerciseRepository; + + private Member member; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스트 유저", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31)); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("partyBookmark - 모임 찜하기") + class PartyBookmarks { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임을 찜하면 저장된 북마크 id를 반환한다") + void createPartyBookmark_returnsBookmarkId() { + // given + PartyBookmark savedBookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 50L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false); + given(partyBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(partyBookmarkRepository.save(any(PartyBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.partyBookmark(member.getId(), party.getId()); + + // then + assertThat(result).isEqualTo(50L); + then(partyBookmarkRepository).should().save(any(PartyBookmark.class)); + } + + @Test + @DisplayName("찜 목록이 15개 이상이면 가장 오래된 북마크를 삭제하고 새로 저장한다") + void createPartyBookmark_deletesOldestWhenLimitExceeded() { + // given + List existingBookmarks = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + existingBookmarks.add(PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build()); + } + + PartyBookmark oldestBookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + + PartyBookmark savedBookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 99L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false); + given(partyBookmarkRepository.findAllByMember(member)).willReturn(existingBookmarks); + given(partyBookmarkRepository.findFirstByMemberOrderByCreatedAtAsc(member)) + .willReturn(Optional.of(oldestBookmark)); + given(partyBookmarkRepository.save(any(PartyBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.partyBookmark(member.getId(), party.getId()); + + // then + assertThat(result).isEqualTo(99L); + then(partyBookmarkRepository).should().delete(oldestBookmark); + then(partyBookmarkRepository).should().save(any(PartyBookmark.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(999L, party.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다") + void partyNotFound_throwsPartyException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(member.getId(), 999L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("비활성화된 모임이면 PartyException(PARTY_IS_DELETED)을 던진다") + void partyIsInactive_throwsPartyException() { + Party inactiveParty = PartyFixture.createParty("삭제된 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(inactiveParty, "id", 20L); + ReflectionTestUtils.setField(inactiveParty, "status", PartyStatus.INACTIVE); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(20L)).willReturn(Optional.of(inactiveParty)); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(member.getId(), 20L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + } + + @Test + @DisplayName("이미 찜한 모임이면 BookmarkException(ALREADY_BOOKMARK)을 던진다") + void alreadyBookmarked_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(true); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(member.getId(), party.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_BOOKMARK)); + + then(partyBookmarkRepository).should(never()).save(any()); + } + } + } + + @Nested + @DisplayName("releasePartyBookmark - 모임 찜 해제") + class ReleasePartyBookmarks { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 모임을 해제하면 북마크를 삭제한다") + void releasePartyBookmark_deletesBookmark() { + // given + PartyBookmark bookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.findByMemberAndParty(member, party)) + .willReturn(Optional.of(bookmark)); + + // when + bookmarkCommandService.releasePartyBookmark(member.getId(), party.getId()); + + // then + then(partyBookmarkRepository).should().delete(bookmark); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releasePartyBookmark(999L, party.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다") + void partyNotFound_throwsPartyException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releasePartyBookmark(member.getId(), 999L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("찜하지 않은 모임이면 BookmarkException(ALREADY_RELEASE_BOOKMARK)을 던진다") + void bookmarkNotFound_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.findByMemberAndParty(member, party)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releasePartyBookmark(member.getId(), party.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK)); + + then(partyBookmarkRepository).should(never()).delete(any()); + } + } + } + + @Nested + @DisplayName("exerciseBookmark - 운동 찜하기") + class ExerciseBookmarkCreate { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("운동을 찜하면 저장된 북마크 id를 반환한다") + void createExerciseBookmark_returnsBookmarkId() { + // given + ExerciseBookmark savedBookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 200L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(false); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(exerciseBookmarkRepository.save(any(ExerciseBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId()); + + // then + assertThat(result).isEqualTo(200L); + then(exerciseBookmarkRepository).should().save(any(ExerciseBookmark.class)); + } + + @Test + @DisplayName("찜 목록이 50개 이상이면 가장 오래된 북마크를 삭제하고 새로 저장한다") + void createExerciseBookmark_deletesOldestWhenLimitExceeded() { + // given + List existingBookmarks = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + existingBookmarks.add(ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build()); + } + + ExerciseBookmark oldestBookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + + ExerciseBookmark savedBookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 300L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(false); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(existingBookmarks); + given(exerciseBookmarkRepository.findFirstByMemberOrderByCreatedAtAsc(member)) + .willReturn(Optional.of(oldestBookmark)); + given(exerciseBookmarkRepository.save(any(ExerciseBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId()); + + // then + assertThat(result).isEqualTo(300L); + then(exerciseBookmarkRepository).should().delete(oldestBookmark); + then(exerciseBookmarkRepository).should().save(any(ExerciseBookmark.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.exerciseBookmark(999L, exercise.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsExerciseException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.exerciseBookmark(member.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("이미 찜한 운동이면 BookmarkException(ALREADY_BOOKMARK)을 던진다") + void alreadyBookmarked_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(true); + + assertThatThrownBy(() -> + bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_BOOKMARK)); + + then(exerciseBookmarkRepository).should(never()).save(any()); + } + } + } + + @Nested + @DisplayName("releaseExerciseBookmark - 운동 찜 해제") + class ReleaseExerciseBookmark { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 운동을 해제하면 북마크를 삭제한다") + void releaseExerciseBookmark_deletesBookmark() { + // given + ExerciseBookmark bookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.findByMemberAndExercise(member, exercise)) + .willReturn(Optional.of(bookmark)); + + // when + bookmarkCommandService.releaseExerciseBookmark(member.getId(), exercise.getId()); + + // then + then(exerciseBookmarkRepository).should().delete(bookmark); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releaseExerciseBookmark(999L, exercise.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsExerciseException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releaseExerciseBookmark(member.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("찜하지 않은 운동이면 BookmarkException(ALREADY_RELEASE_BOOKMARK)을 던진다") + void bookmarkNotFound_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.findByMemberAndExercise(member, exercise)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releaseExerciseBookmark(member.getId(), exercise.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK)); + + then(exerciseBookmarkRepository).should(never()).delete(any()); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java new file mode 100644 index 000000000..75602159e --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java @@ -0,0 +1,503 @@ +package umc.cockple.demo.domain.bookmark.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.converter.BookmarkConverter; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.dto.GetAllExerciseBookmarksResponseDTO; +import umc.cockple.demo.domain.bookmark.dto.GetAllPartyBookmarkResponseDTO; +import umc.cockple.demo.domain.bookmark.enums.BookmarkedExerciseOrderType; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.ActivityTime; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookmarkQueryService") +class BookmarkQueryServiceTest { + + @InjectMocks + private BookmarkQueryService bookmarkQueryService; + + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private PartyBookmarkRepository partyBookmarkRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private BookmarkConverter bookmarkConverter; + + private Member member; + private Party party; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스트 유저", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + } + + @Nested + @DisplayName("getAllExerciseBookmarks - 찜한 운동 목록 조회") + class GetAllExerciseBookmarks { + + private Exercise oldExercise; + private Exercise newExercise; + + @BeforeEach + void setUp() { + oldExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 6, 1)); + ReflectionTestUtils.setField(oldExercise, "id", 101L); + + newExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31)); + ReflectionTestUtils.setField(newExercise, "id", 102L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 운동이 없으면 빈 목록을 반환한다") + void noBookmarks_returnsEmptyList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) + .willReturn(new ArrayList<>()); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) + .willReturn(new ArrayList<>()); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("LATEST 정렬 시 최신순으로 반환한다") + void latestOrder_returnsNewestFirst() { + // given + ExerciseBookmark bookmarkOld = ExerciseBookmark.builder() + .member(member).exercise(oldExercise).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + ExerciseBookmark bookmarkNew = ExerciseBookmark.builder() + .member(member).exercise(newExercise).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + // 레포지토리에서 오래된 순서로 반환 + List bookmarks = new ArrayList<>(List.of(bookmarkOld, bookmarkNew)); + + GetAllExerciseBookmarksResponseDTO dtoOld = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(101L).partyName("테스트 모임").build(); + GetAllExerciseBookmarksResponseDTO dtoNew = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(102L).partyName("테스트 모임").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(bookmarks); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) + .willReturn(List.of(party.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) + .willReturn(List.of(oldExercise.getId(), newExercise.getId())); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoNew); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoOld); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).exerciseId()).isEqualTo(102L); // 최신 것이 먼저 + assertThat(result.get(1).exerciseId()).isEqualTo(101L); + } + + @Test + @DisplayName("EARLIEST 정렬 시 오래된 순으로 반환한다") + void earliestOrder_returnsOldestFirst() { + // given + ExerciseBookmark bookmarkOld = ExerciseBookmark.builder() + .member(member).exercise(oldExercise).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + ExerciseBookmark bookmarkNew = ExerciseBookmark.builder() + .member(member).exercise(newExercise).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + // 레포지토리에서 최신 순서로 반환 + List bookmarks = new ArrayList<>(List.of(bookmarkNew, bookmarkOld)); + + GetAllExerciseBookmarksResponseDTO dtoOld = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(101L).partyName("테스트 모임").build(); + GetAllExerciseBookmarksResponseDTO dtoNew = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(102L).partyName("테스트 모임").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(bookmarks); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) + .willReturn(List.of(party.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) + .willReturn(List.of(oldExercise.getId(), newExercise.getId())); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoOld); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoNew); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.EARLIEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).exerciseId()).isEqualTo(101L); // 오래된 것이 먼저 + assertThat(result.get(1).exerciseId()).isEqualTo(102L); + } + + @Test + @DisplayName("includeParty, includeExercise 정보를 정확히 반영하여 변환한다") + void convertsBookmarkWithCorrectIncludeFlags() { + // given + Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31)); + ReflectionTestUtils.setField(exercise, "id", 101L); + + ExerciseBookmark bookmark = ExerciseBookmark.builder() + .member(member).exercise(exercise).build(); + ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now()); + + GetAllExerciseBookmarksResponseDTO dto = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(101L).includeParty(true).includeExercise(false).build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>(List.of(bookmark))); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(eq(member.getId()), anyList())) + .willReturn(List.of(party.getId())); // 모임 멤버 + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(eq(member.getId()), anyList())) + .willReturn(new ArrayList<>()); // 운동 미참여 + given(bookmarkConverter.exerciseBookmarkToDTO(bookmark, true, false)).willReturn(dto); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).includeParty()).isTrue(); + assertThat(result.get(0).includeExercise()).isFalse(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkQueryService.getAllExerciseBookmarks(999L, BookmarkedExerciseOrderType.LATEST)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("getAllPartyBookmarks - 찜한 모임 목록 조회") + class GetAllPartyBookmarks { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 모임이 없으면 빈 목록을 반환한다") + void noBookmarks_returnsEmptyList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)).willReturn(new ArrayList<>()); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("LATEST 정렬 시 최신순으로 반환한다") + void latestOrder_returnsNewestFirst() { + // given + Party partyA = PartyFixture.createParty("모임A", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(partyA, "id", 11L); + + Party partyB = PartyFixture.createParty("모임B", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "종로구")); + ReflectionTestUtils.setField(partyB, "id", 12L); + + PartyBookmark bookmarkOld = PartyBookmark.builder() + .member(member).party(partyA) + .orderType(PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + PartyBookmark bookmarkNew = PartyBookmark.builder() + .member(member).party(partyB) + .orderType(PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + GetAllPartyBookmarkResponseDTO dtoA = GetAllPartyBookmarkResponseDTO.builder() + .partyId(11L).partyName("모임A").build(); + GetAllPartyBookmarkResponseDTO dtoB = GetAllPartyBookmarkResponseDTO.builder() + .partyId(12L).partyName("모임B").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmarkOld, bookmarkNew))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkNew), any(), any(), any())) + .willReturn(dtoB); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkOld), any(), any(), any())) + .willReturn(dtoA); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).partyId()).isEqualTo(12L); // 최신 것이 먼저 + assertThat(result.get(1).partyId()).isEqualTo(11L); + } + + @Test + @DisplayName("OLDEST 정렬 시 오래된 순으로 반환한다") + void oldestOrder_returnsOldestFirst() { + // given + Party partyA = PartyFixture.createParty("모임A", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(partyA, "id", 11L); + + Party partyB = PartyFixture.createParty("모임B", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "종로구")); + ReflectionTestUtils.setField(partyB, "id", 12L); + + PartyBookmark bookmarkOld = PartyBookmark.builder() + .member(member).party(partyA) + .orderType(PartyOrderType.OLDEST).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + PartyBookmark bookmarkNew = PartyBookmark.builder() + .member(member).party(partyB) + .orderType(PartyOrderType.OLDEST).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + GetAllPartyBookmarkResponseDTO dtoA = GetAllPartyBookmarkResponseDTO.builder() + .partyId(11L).partyName("모임A").build(); + GetAllPartyBookmarkResponseDTO dtoB = GetAllPartyBookmarkResponseDTO.builder() + .partyId(12L).partyName("모임B").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmarkNew, bookmarkOld))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkOld), any(), any(), any())) + .willReturn(dtoA); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkNew), any(), any(), any())) + .willReturn(dtoB); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.OLDEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).partyId()).isEqualTo(11L); // 오래된 것이 먼저 + assertThat(result.get(1).partyId()).isEqualTo(12L); + } + + @Test + @DisplayName("EXERCISE_COUNT 정렬 시 운동 횟수 많은 순으로 반환한다") + void exerciseCountOrder_returnsMostExercisedFirst() { + // given + Party partyLow = PartyFixture.createParty("운동 적은 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(partyLow, "id", 11L); + ReflectionTestUtils.setField(partyLow, "exerciseCount", 2); + + Party partyHigh = PartyFixture.createParty("운동 많은 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "종로구")); + ReflectionTestUtils.setField(partyHigh, "id", 12L); + ReflectionTestUtils.setField(partyHigh, "exerciseCount", 10); + + PartyBookmark bookmarkLow = PartyBookmark.builder() + .member(member).party(partyLow) + .orderType(PartyOrderType.EXERCISE_COUNT).build(); + ReflectionTestUtils.setField(bookmarkLow, "createdAt", LocalDateTime.now().minusDays(1)); + + PartyBookmark bookmarkHigh = PartyBookmark.builder() + .member(member).party(partyHigh) + .orderType(PartyOrderType.EXERCISE_COUNT).build(); + ReflectionTestUtils.setField(bookmarkHigh, "createdAt", LocalDateTime.now().minusDays(2)); + + GetAllPartyBookmarkResponseDTO dtoLow = GetAllPartyBookmarkResponseDTO.builder() + .partyId(11L).partyName("운동 적은 모임").exerciseCnt(2).build(); + GetAllPartyBookmarkResponseDTO dtoHigh = GetAllPartyBookmarkResponseDTO.builder() + .partyId(12L).partyName("운동 많은 모임").exerciseCnt(10).build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmarkLow, bookmarkHigh))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkHigh), any(), any(), any())) + .willReturn(dtoHigh); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkLow), any(), any(), any())) + .willReturn(dtoLow); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.EXERCISE_COUNT); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).partyId()).isEqualTo(12L); // 운동 많은 모임이 먼저 + assertThat(result.get(1).partyId()).isEqualTo(11L); + } + + @Test + @DisplayName("파티에 미래 운동이 있을 때 가장 가까운 운동 정보를 함께 반환한다") + void partyWithFutureExercise_returnsLatestExerciseInfo() { + // given + Exercise futureExercise = ExerciseFixture.createExercise(party, + LocalDate.now().plusDays(7), LocalTime.of(10, 0), true, false); + party.addExercise(futureExercise); + + PartyBookmark bookmark = PartyBookmark.builder() + .member(member).party(party) + .orderType(umc.cockple.demo.domain.party.enums.PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now()); + + GetAllPartyBookmarkResponseDTO dto = GetAllPartyBookmarkResponseDTO.builder() + .partyId(party.getId()) + .latestExerciseDate(LocalDate.now().plusDays(7)) + .latestExerciseTime(ActivityTime.MORNING) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmark))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmark), + eq(futureExercise), eq(ActivityTime.MORNING), any())) + .willReturn(dto); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).latestExerciseDate()).isEqualTo(LocalDate.now().plusDays(7)); + assertThat(result.get(0).latestExerciseTime()).isEqualTo(ActivityTime.MORNING); + } + + @Test + @DisplayName("파티에 미래 운동이 없을 때 exercise는 null로 변환된다") + void partyWithNoFutureExercise_passesNullExercise() { + // given - 과거 운동만 있는 파티 + Exercise pastExercise = ExerciseFixture.createExercise(party, + LocalDate.now().minusDays(1), LocalTime.of(10, 0), true, false); + party.addExercise(pastExercise); + + PartyBookmark bookmark = PartyBookmark.builder() + .member(member).party(party) + .orderType(umc.cockple.demo.domain.party.enums.PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now()); + + GetAllPartyBookmarkResponseDTO dto = GetAllPartyBookmarkResponseDTO.builder() + .partyId(party.getId()) + .latestExerciseDate(null) + .latestExerciseTime(null) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmark))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmark), eq(null), eq(null), any())) + .willReturn(dto); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).hasSize(1); + // null exercise 로 converter가 호출되었는지 검증 + verify(bookmarkConverter, times(1)) + .partyBookmarkToDTO(eq(bookmark), eq(null), eq(null), any()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkQueryService.getAllPartyBookmarks(999L, PartyOrderType.LATEST)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } +} From 7215552d619a6f03583e8cd83444b6fe022ecfed Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:49:07 +0900 Subject: [PATCH 18/20] =?UTF-8?q?[test/#551]=20=EC=9A=B4=EB=8F=99=20Query?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: opencode 사용을 위해서 AGENTS.md 파일은 gitignore 처리 * test: ExerciseQuery getExerciseDetail 테스트 정리 * test: 운동 수정용 상세 조회 테스트 코드 작성 * test: 내가 초대한 게스트 조회 API 테스트 코드 작성 * test: 모임 운동 캘린더 조회 API 테스트 코드 작성 * test: 모임 운동 캘린더 조회 API에서 기본 기간 테스트도 추가 * test: 내 운동 캘린더 조회 API 테스트 코드 작성 * chore: 테스트 환경의 시간 존을 Asia로 설정 * test: 내 모임 운동 조회 API 테스트 코드 작성 * chore: claude code와 omc 사용을 위해 깃 이그노어 처리 * test: 내 모임 운동 캘린더 조회 API 테스트 코드 작성 * test: 사용자 추천 운동 조회 API 테스트 코드 작성 * test: ExerciseFixture에서 오버로딩 호출 방식으로 구조 변경 * fix: 기존 모임 운동 캘린더 조회 통합 테스트에서 날짜가 같아 깨지던 테스트를 수정 * test: 내 참여 운동 조회 API 테스트 코드 작성 * test: 건물 운동 상세 조회 API 테스트 코드 작성 * test: 월간 운동 건물 지도 데이터 조회 API 테스트 코드 작성 * test: 추천 운동 캘린더 조회 API 테스트 코드 작성 --- .gitignore | 6 +- ...va => ExerciseCommandIntegrationTest.java} | 203 +- .../ExerciseQueryIntegrationTest.java | 1699 +++++++++++++++ ...ExerciseRecommendationIntegrationTest.java | 281 +++ .../service/ExerciseQueryServiceTest.java | 1896 ++++++++++++++++- .../ExerciseRecommendationServiceTest.java | 304 +++ .../support/ExerciseCalendarTestHelper.java | 30 + .../demo/support/IntegrationTestConfig.java | 3 +- .../demo/support/fixture/ExerciseFixture.java | 75 +- .../demo/support/fixture/GuestFixture.java | 10 +- .../demo/support/fixture/MemberFixture.java | 8 + src/test/resources/mysql-conf/timezone.cnf | 2 + 12 files changed, 4256 insertions(+), 261 deletions(-) rename src/test/java/umc/cockple/demo/domain/exercise/integration/{ExerciseIntegrationTest.java => ExerciseCommandIntegrationTest.java} (82%) create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java create mode 100644 src/test/resources/mysql-conf/timezone.cnf diff --git a/.gitignore b/.gitignore index 69585293d..c03094428 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,8 @@ terraform/terraform.tfstate.backup terraform/terraform.tfvars ### firebase ### -src/main/resources/firebase/*.json \ No newline at end of file +src/main/resources/firebase/*.json + +### Claude / OMC ### +.claude/ +.omc/ \ No newline at end of file diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java similarity index 82% rename from src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java rename to src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java index 43d522a4d..fa378c12c 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java @@ -44,7 +44,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -class ExerciseIntegrationTest extends IntegrationTestBase { +class ExerciseCommandIntegrationTest extends IntegrationTestBase { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; @@ -1096,205 +1096,4 @@ void guestNotInvitedByMember() throws Exception { } } - @Nested - @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회") - class GetExerciseDetail { - - private Exercise exercise; - - @BeforeEach - void setUp() { - exercise = exerciseRepository.save( - ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("응답의 모든 주요 필드가 올바르게 반환된다") - void 응답의_모든_주요_필드가_올바르게_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isManager").value(true)) - .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관")) - .andExpect(jsonPath("$.data.info.location").value("서울특별시 강남구 테헤란로 1")) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) - .andExpect(jsonPath("$.data.participants.totalCount").value(10)) - .andExpect(jsonPath("$.data.participants.manCount").value(1)) - .andExpect(jsonPath("$.data.participants.womenCount").value(0)) - .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) - .andExpect(jsonPath("$.data.participants.list[0].name").isString()) - .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE")) - .andExpect(jsonPath("$.data.participants.list[0].level").isString()) - .andExpect(jsonPath("$.data.participants.list[0].participantType").isString()) - .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)) - .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(0)); - } - - @Test - @DisplayName("활성 회원 참가자는 isWithdrawn false로 반환된다") - void 활성_회원_참가자는_isWithdrawn_false로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)); - } - - @Test - @DisplayName("탈퇴 회원 참가자는 isWithdrawn true로 반환된다") - void 탈퇴_회원_참가자는_isWithdrawn_true로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - Member withdrawnMember = memberRepository.save( - MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 8888L)); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(withdrawnMember, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(true)); - } - - @Test - @DisplayName("모임장이 조회하면 isManager true로 반환된다") - void 모임장이_조회하면_isManager_true로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isManager").value(true)); - } - - @Test - @DisplayName("일반 멤버가 조회하면 isManager false로 반환된다") - void 일반_멤버가_조회하면_isManager_false로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isManager").value(false)); - } - - @Test - @DisplayName("정원 초과 참가자는 대기자로 반환된다") - void 정원_초과_참가자는_대기자로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - Exercise smallExercise = exerciseRepository.save( - ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) - .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)); - } - - @Test - @DisplayName("게스트 참가자는 inviterName이 반환된다") - void 게스트_참가자는_inviterName이_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.list[0].participantType").value("GUEST")) - .andExpect(jsonPath("$.data.participants.list[0].inviterName").isString()); - } - - @Test - @DisplayName("먼저 가입한 참가자가 더 낮은 participantNumber를 받는다") - void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) - .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) - .andExpect(jsonPath("$.data.participants.list[1].participantNumber").value(2)) - .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName())) - .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())); - } - - @Test - @DisplayName("대기자의 성별 카운트가 올바르게 반환된다") - void 대기자의_성별_카운트가_올바르게_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - // 정원 1명짜리 운동: normalMember(MALE) 참가, subManager(FEMALE) 대기 - Exercise smallExercise = exerciseRepository.save( - ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) - .andExpect(jsonPath("$.data.participants.manCount").value(1)) - .andExpect(jsonPath("$.data.participants.womenCount").value(0)) - .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) - .andExpect(jsonPath("$.data.waiting.manCount").value(0)) - .andExpect(jsonPath("$.data.waiting.womenCount").value(1)); - } - - @Test - @DisplayName("남성과 여성 참가자가 있을 때 성별 카운트가 올바르게 반환된다") - void 남성과_여성_참가자가_있을_때_성별_카운트가_올바르게_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - // normalMember: MALE, subManager: FEMALE - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) - .andExpect(jsonPath("$.data.participants.manCount").value(1)) - .andExpect(jsonPath("$.data.participants.womenCount").value(1)); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 운동이면 에러를 반환한다") - void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - mockMvc.perform(get("/api/exercises/{exerciseId}", 999L)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("존재하지 않는 멤버면 에러를 반환한다") - void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(999L, "없는멤버"); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - } - } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java new file mode 100644 index 000000000..6e8b1b8e7 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -0,0 +1,1699 @@ +package umc.cockple.demo.domain.exercise.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.enums.ActivityTime; +import umc.cockple.demo.domain.party.enums.ParticipationType; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ExerciseQueryIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired MemberAddrRepository memberAddrRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired GuestRepository guestRepository; + @Autowired ExerciseBookmarkRepository exerciseBookmarkRepository; + @Autowired DataSource dataSource; + + private Member manager; + private Member subManager; + private Member normalMember; + private Member outsider; + private Party party; + + @BeforeEach + void setUp() { + manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L, LocalDate.of(2000, 1, 1))); + subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L, LocalDate.of(2000, 1, 1))); + normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L, LocalDate.of(2000, 1, 1))); + outsider = memberRepository.save(MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 1004L, LocalDate.of(2001, 1, 1))); + + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); + + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER)); + } + + @AfterEach + void tearDown() { + guestRepository.deleteAll(); + exerciseBookmarkRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + @Nested + @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회") + class GetExerciseDetail { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("응답의 모든 주요 필드가 올바르게 반환된다") + void 응답의_모든_주요_필드가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise smallExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(true)) + .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.info.location").value("서울특별시 강남구 테헤란로 1")) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) + .andExpect(jsonPath("$.data.participants.totalCount").value(1)) + .andExpect(jsonPath("$.data.participants.manCount").value(1)) + .andExpect(jsonPath("$.data.participants.womenCount").value(0)) + .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) + .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE")) + .andExpect(jsonPath("$.data.participants.list[0].level").isString()) + .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("party_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)) + .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) + .andExpect(jsonPath("$.data.waiting.manCount").value(0)) + .andExpect(jsonPath("$.data.waiting.womenCount").value(1)) + .andExpect(jsonPath("$.data.waiting.list[0].name").value(subManager.getMemberName())) + .andExpect(jsonPath("$.data.waiting.list[0].gender").value("FEMALE")) + .andExpect(jsonPath("$.data.waiting.list[0].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.waiting.list[0].partyPosition").value("party_SUBMANAGER")) + .andExpect(jsonPath("$.data.waiting.list[0].isWithdrawn").value(false)); + } + + @Test + @DisplayName("모임장이 조회하면 isManager true로 반환된다") + void 모임장이_조회하면_isManager_true로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(true)); + } + + @Test + @DisplayName("일반 멤버가 조회하면 isManager false로 반환된다") + void 일반_멤버가_조회하면_isManager_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(false)); + } + + @Test + @DisplayName("부모임장이 조회해도 isManager false로 반환된다") + void 부모임장이_조회해도_isManager_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(false)); + } + + @Test + @DisplayName("모임 외부 회원이 조회해도 isManager false로 반환된다") + void 모임_외부_회원이_조회해도_isManager_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(false)) + .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관")); + } + + @Test + @DisplayName("정원 초과 참가자는 대기자로 반환된다") + void 정원_초과_참가자는_대기자로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise smallExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) + .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)); + } + + @Test + @DisplayName("먼저 가입한 참가자가 더 낮은 participantNumber를 받는다") + void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) + .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) + .andExpect(jsonPath("$.data.participants.list[1].participantNumber").value(2)) + .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())); + } + + @Test + @DisplayName("참가자의 성별 카운트가 올바르게 반환된다") + void 참가자의_성별_카운트가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) + .andExpect(jsonPath("$.data.participants.manCount").value(1)) + .andExpect(jsonPath("$.data.participants.womenCount").value(1)); + } + + @Test + @DisplayName("대기자의 성별 카운트가 올바르게 반환된다") + void 대기자의_성별_카운트가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise smallExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) + .andExpect(jsonPath("$.data.participants.manCount").value(1)) + .andExpect(jsonPath("$.data.participants.womenCount").value(0)) + .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) + .andExpect(jsonPath("$.data.waiting.manCount").value(0)) + .andExpect(jsonPath("$.data.waiting.womenCount").value(1)); + } + + @Test + @DisplayName("참가자 유형별 partyPosition이 올바르게 반환된다") + void 참가자_유형별_partyPosition이_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(manager, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + memberExerciseRepository.save(MemberFixture.createExternalMemberExercise(outsider, exercise)); + guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].name").value(manager.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("party_MANAGER")) + .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[1].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[1].partyPosition").value("party_SUBMANAGER")) + .andExpect(jsonPath("$.data.participants.list[2].name").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[2].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[2].partyPosition").value("party_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[3].name").value(outsider.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[3].participantType").value("EXTERNAL_PARTICIPANT")) + .andExpect(jsonPath("$.data.participants.list[3].partyPosition").value(nullValue())) + .andExpect(jsonPath("$.data.participants.list[4].name").value("게스트")) + .andExpect(jsonPath("$.data.participants.list[4].participantType").value("GUEST")) + .andExpect(jsonPath("$.data.participants.list[4].partyPosition").value(nullValue())); + } + + @Test + @DisplayName("게스트 참가자의 inviterName이 반환된다") + void 게스트_참가자의_inviterName이_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].participantType").value("GUEST")) + .andExpect(jsonPath("$.data.participants.list[0].inviterName").value(manager.getMemberName())); + } + + @Test + @DisplayName("활성 회원 참가자는 isWithdrawn false로 반환된다") + void 활성_회원_참가자는_isWithdrawn_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)); + } + + @Test + @DisplayName("탈퇴 회원 참가자는 isWithdrawn true로 반환된다") + void 탈퇴_회원_참가자는_isWithdrawn_true로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Member withdrawnMember = memberRepository.save( + MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 8888L)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(withdrawnMember, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(true)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 에러를 반환한다") + void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/exercises/{exerciseId}/for-edit - 운동 수정용 상세 조회") + class GetExerciseForEdit { + + private Exercise exercise; + + @BeforeEach + void setUp() { + Exercise exerciseForEdit = ExerciseFixture.createExerciseWithAddr( + party, LocalDate.of(2026, 3, 24), 18); + ReflectionTestUtils.setField(exerciseForEdit, "endTime", LocalTime.of(12, 30)); + ReflectionTestUtils.setField(exerciseForEdit, "notice", "수정 공지사항"); + exercise = exerciseRepository.save(exerciseForEdit); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("응답의 모든 수정용 필드가 올바르게 반환된다") + void 응답의_모든_수정용_필드가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-03-24")) + .andExpect(jsonPath("$.data.buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.roadAddress").value("서울특별시 강남구 테헤란로 1")) + .andExpect(jsonPath("$.data.latitude").value(37.5)) + .andExpect(jsonPath("$.data.longitude").value(127.0)) + .andExpect(jsonPath("$.data.startTime").value("10:00:00")) + .andExpect(jsonPath("$.data.endTime").value("12:30:00")) + .andExpect(jsonPath("$.data.maxCapacity").value(18)) + .andExpect(jsonPath("$.data.allowMemberGuestsInvitation").value(true)) + .andExpect(jsonPath("$.data.allowExternalGuests").value(false)) + .andExpect(jsonPath("$.data.notice").value("수정 공지사항")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 에러를 반환한다") + void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/exercises/{exerciseId}/guests - 내가 초대한 운동 게스트 조회") + class GetMyInvitedGuests { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다") + void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + var myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE); + guestRepository.save(myFirstGuest); + + var otherGuest = GuestFixture.createGuest(exercise, normalMember.getId(), "다른사람게스트", Gender.MALE); + guestRepository.save(otherGuest); + + var mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE); + guestRepository.save(mySecondGuest); + + mockMvc.perform(get("/api/exercises/{exerciseId}/guests", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.maleCount").value(1)) + .andExpect(jsonPath("$.data.femaleCount").value(1)) + .andExpect(jsonPath("$.data.list[0].guestId").isNumber()) + .andExpect(jsonPath("$.data.list[0].isWaiting").value(false)) + .andExpect(jsonPath("$.data.list[0].participantNumber").value(1)) + .andExpect(jsonPath("$.data.list[0].name").value("내게스트1")) + .andExpect(jsonPath("$.data.list[0].gender").value("MALE")) + .andExpect(jsonPath("$.data.list[0].level").value("B")) + .andExpect(jsonPath("$.data.list[0].inviterName").value(manager.getMemberName())) + .andExpect(jsonPath("$.data.list[1].guestId").isNumber()) + .andExpect(jsonPath("$.data.list[1].isWaiting").value(true)) + .andExpect(jsonPath("$.data.list[1].participantNumber").value(2)) + .andExpect(jsonPath("$.data.list[1].name").value("내게스트2")) + .andExpect(jsonPath("$.data.list[1].gender").value("FEMALE")) + .andExpect(jsonPath("$.data.list[1].level").value("B")) + .andExpect(jsonPath("$.data.list[1].inviterName").value(manager.getMemberName())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_에러를_반환한다") + void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/guests", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("존재하지_않는_멤버면_에러를_반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/{exerciseId}/guests", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/parties/{partyId}/exercises/calender - 모임 운동 캘린더 조회") + class GetPartyExerciseCalendar { + + private Exercise exercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 24))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("요청한 기간의 모임 운동 캘린더가 반환된다") + void 요청한_기간의_모임_운동_캘린더가_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()) + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.isMember").value(true)) + .andExpect(jsonPath("$.data.partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[1].date").value("2026-03-24")) + .andExpect(jsonPath("$.data.weeks[0].days[1].dayOfWeek").value("TUESDAY")) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].exerciseId").value(exercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].currentParticipants").value(1)) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].maxCapacity").value(10)) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].isParticipating").value(true)); + } + + @Test + @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_운동이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()) + .param("startDate", "2026-03-30") + .param("endDate", "2026-04-05")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-30")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.isMember").value(false)) + .andExpect(jsonPath("$.data.partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate)); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, defaultExercise)); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].isParticipating").value(true)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 에러를 반환한다") + void 시작일과_종료일이_함께_오지_않으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()) + .param("startDate", startDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/exercises/my/calender - 내 운동 캘린더 조회") + class GetMyExerciseCalendar { + + private Exercise exercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("요청한 기간의 내 운동 캘린더가 반환된다") + void 요청한_기간의_내_운동_캘린더가_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(get("/api/exercises/my/calender") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[2].date").value("2026-03-25")) + .andExpect(jsonPath("$.data.weeks[0].days[2].dayOfWeek").value("WEDNESDAY")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].startTime").value("10:00:00")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].endTime").value(nullValue())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].profileImageUrl").value(nullValue())); + } + + @Test + @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/my/calender") + .param("startDate", "2026-03-30") + .param("endDate", "2026-04-05")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-30")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(8); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate)); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, defaultExercise)); + + mockMvc.perform(get("/api/exercises/my/calender")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].partyId").value(party.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 에러를 반환한다") + void 시작일과_종료일이_함께_오지_않으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/my/calender") + .param("startDate", startDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/exercises/parties/my - 내 모임 운동 조회") + class GetMyPartyExercise { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("시작한 운동은 제외하고 내가 속한 모임의 예정된 운동을 최대 6개까지 시간순으로 반환한다") + void 시작한_운동은_제외하고_내가_속한_모임의_예정된_운동을_최대_6개까지_시간순으로_반환한다() throws Exception { + PartyAddr otherAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "송파구")); + Party otherParty = partyRepository.save(PartyFixture.createParty("다른 모임", outsider.getId(), otherAddr)); + + Exercise pastExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); + Exercise startedTodayExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.now()); + ReflectionTestUtils.setField(startedTodayExercise, "startTime", LocalTime.now().minusMinutes(30)); + startedTodayExercise = exerciseRepository.save(startedTodayExercise); + Exercise firstExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(1))); + Exercise secondExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(2))); + Exercise thirdExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(3))); + Exercise fourthExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(4))); + Exercise fifthExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(5))); + Exercise sixthExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(6))); + exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(7))); + exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(otherParty, LocalDate.now().plusDays(1))); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(6)) + .andExpect(jsonPath("$.data.exercises.length()").value(6)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(firstExercise.getId())) + .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(secondExercise.getId())) + .andExpect(jsonPath("$.data.exercises[2].exerciseId").value(thirdExercise.getId())) + .andExpect(jsonPath("$.data.exercises[3].exerciseId").value(fourthExercise.getId())) + .andExpect(jsonPath("$.data.exercises[4].exerciseId").value(fifthExercise.getId())) + .andExpect(jsonPath("$.data.exercises[5].exerciseId").value(sixthExercise.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.exercises[0].profileImageUrl").value(nullValue())); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 응답을 반환한다") + void 속한_모임이_없으면_빈_응답을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/parties/my")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/exercises/parties/my/calendar - 내 모임 운동 캘린더 조회") + class GetMyPartyExerciseCalendar { + + private Exercise exercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("요청한 기간의 내 모임 운동 캘린더가 반환된다") + void 요청한_기간의_내_모임_운동_캘린더가_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[2].date").value("2026-03-25")) + .andExpect(jsonPath("$.data.weeks[0].days[2].dayOfWeek").value("WEDNESDAY")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].nowCapacity").value(0)); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다") + void 속한_모임이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", "2026-03-30") + .param("endDate", "2026-04-05")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-30")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(8); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate)); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].partyId").value(party.getId())); + } + + @Test + @DisplayName("POPULARITY 정렬 옵션으로 조회 시 정상 반환된다") + void POPULARITY_정렬_옵션으로_조회_시_정상_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("orderType", "POPULARITY") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + + @Nested + @DisplayName("GET /api/exercises/my - 내 참여 운동 조회") + class GetMyExercises { + + private final List upcomingExercises = new ArrayList<>(); + private final List completedExercises = new ArrayList<>(); + + @BeforeEach + void setUp() { + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + for (int day = 1; day <= 10; day++) { + Exercise exercise = saveParticipatedExercise(LocalDate.of(2099, 1, day), LocalTime.of(10, 0), 10, true); + upcomingExercises.add(exercise); + } + + Exercise featuredUpcomingExercise = upcomingExercises.get(9); + ReflectionTestUtils.setField(featuredUpcomingExercise, "startTime", LocalTime.of(7, 30)); + ReflectionTestUtils.setField(featuredUpcomingExercise, "endTime", LocalTime.of(9, 0)); + ReflectionTestUtils.setField(featuredUpcomingExercise, "maxCapacity", 20); + ReflectionTestUtils.setField(featuredUpcomingExercise, "partyGuestAccept", false); + featuredUpcomingExercise = exerciseRepository.save(featuredUpcomingExercise); + upcomingExercises.set(9, featuredUpcomingExercise); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, featuredUpcomingExercise)); + guestRepository.save(GuestFixture.createGuest(featuredUpcomingExercise, manager.getId())); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(normalMember) + .exercise(featuredUpcomingExercise) + .build()); + + for (int day = 1; day <= 8; day++) { + Exercise exercise = saveParticipatedExercise(LocalDate.of(2024, 1, day), LocalTime.of(10, 0), 10, true); + completedExercises.add(exercise); + } + + exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 2, 1))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("파라미터 없이 호출하면 ALL 최신순 기본값과 15개 페이징이 적용된다") + void 파라미터_없이_호출하면_ALL_최신순_기본값과_15개_페이징이_적용된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.exercises.length()").value(15)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(9).getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.exercises[0].date").value("2099-01-10")) + .andExpect(jsonPath("$.data.exercises[0].dayOfWeek").value("SATURDAY")) + .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.exercises[0].startTime").value("07:30:00")) + .andExpect(jsonPath("$.data.exercises[0].endTime").value("09:00:00")) + .andExpect(jsonPath("$.data.exercises[0].femaleLevel[0]").value("B조")) + .andExpect(jsonPath("$.data.exercises[0].maleLevel[0]").value("A조")) + .andExpect(jsonPath("$.data.exercises[0].currentParticipants").value(3)) + .andExpect(jsonPath("$.data.exercises[0].maxCapacity").value(20)) + .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(false)) + .andExpect(jsonPath("$.data.exercises[0].partyGuestInviteAccept").value(false)) + .andExpect(jsonPath("$.data.exercises[14].exerciseId").value(completedExercises.get(3).getId())); + } + + @Test + @DisplayName("두 번째 페이지를 조회하면 남은 3개 운동과 hasNext false를 반환한다") + void 두_번째_페이지를_조회하면_남은_3개_운동과_hasNext_false를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("page", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises.length()").value(3)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(2).getId())) + .andExpect(jsonPath("$.data.exercises[2].exerciseId").value(completedExercises.get(0).getId())); + } + + @Test + @DisplayName("UPCOMING 필터는 예정 운동만 최신순 기본정렬로 반환한다") + void UPCOMING_필터는_예정_운동만_최신순_기본정렬로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "UPCOMING")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(10)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(0).getId())) + .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(false)) + .andExpect(jsonPath("$.data.exercises[9].exerciseId").value(upcomingExercises.get(9).getId())) + .andExpect(jsonPath("$.data.exercises[9].date").value("2099-01-10")); + } + + @Test + @DisplayName("UPCOMING 필터에 OLDEST 정렬을 주면 반대 순서로 반환한다") + void UPCOMING_필터에_OLDEST_정렬을_주면_반대_순서로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "UPCOMING") + .param("orderType", "OLDEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(10)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(9).getId())) + .andExpect(jsonPath("$.data.exercises[9].exerciseId").value(upcomingExercises.get(0).getId())); + } + + @Test + @DisplayName("COMPLETED 필터는 완료 운동만 최신순 기본정렬로 반환한다") + void COMPLETED_필터는_완료_운동만_최신순_기본정렬로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "COMPLETED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(8)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(7).getId())) + .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(true)) + .andExpect(jsonPath("$.data.exercises[7].exerciseId").value(completedExercises.get(0).getId())); + } + + @Test + @DisplayName("COMPLETED 필터에 OLDEST 정렬을 주면 반대 순서로 반환한다") + void COMPLETED_필터에_OLDEST_정렬을_주면_반대_순서로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "COMPLETED") + .param("orderType", "OLDEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(8)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(0).getId())) + .andExpect(jsonPath("$.data.exercises[7].exerciseId").value(completedExercises.get(7).getId())); + } + + @Test + @DisplayName("ALL 필터에 OLDEST 정렬을 주면 가장 오래된 완료 운동부터 반환한다") + void ALL_필터에_OLDEST_정렬을_주면_가장_오래된_완료_운동부터_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("orderType", "OLDEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(0).getId())) + .andExpect(jsonPath("$.data.exercises[14].exerciseId").value(upcomingExercises.get(6).getId())); + } + + @Test + @DisplayName("참여한 운동이 없으면 빈 응답을 반환한다") + void 참여한_운동이_없으면_빈_응답을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/my")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("잘못된 필터 타입이면 400을 반환한다") + void 잘못된_필터_타입이면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "INVALID")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 정렬 타입이면 400을 반환한다") + void 잘못된_정렬_타입이면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("orderType", "INVALID")) + .andExpect(status().isBadRequest()); + } + } + + private Exercise saveParticipatedExercise(LocalDate date, LocalTime startTime, + int maxCapacity, boolean partyGuestAccept) { + Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity); + ReflectionTestUtils.setField(exercise, "startTime", startTime); + ReflectionTestUtils.setField(exercise, "partyGuestAccept", partyGuestAccept); + + Exercise savedExercise = exerciseRepository.save(exercise); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, savedExercise)); + return savedExercise; + } + } + + @Nested + @DisplayName("GET /api/buildings/exercises/{date} - 건물 운동 상세 조회") + class GetBuildingExerciseDetails { + + private final LocalDate targetDate = LocalDate.of(2026, 5, 10); + private final String targetBuildingName = "콕플 타워"; + private final String targetStreetAddr = "서울특별시 강남구 테헤란로 10"; + private Exercise morningExercise; + private Exercise eveningExercise; + + @BeforeEach + void setUp() { + eveningExercise = saveBuildingExercise(targetBuildingName, targetStreetAddr, + targetDate, LocalTime.of(19, 0), LocalTime.of(21, 0)); + morningExercise = saveBuildingExercise(targetBuildingName, targetStreetAddr, + targetDate, LocalTime.of(9, 0), LocalTime.of(11, 0)); + + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(normalMember) + .exercise(eveningExercise) + .build()); + + saveBuildingExercise("다른 건물", targetStreetAddr, + targetDate, LocalTime.of(13, 0), LocalTime.of(15, 0)); + saveBuildingExercise(targetBuildingName, "서울특별시 강남구 테헤란로 99", + targetDate, LocalTime.of(16, 0), LocalTime.of(18, 0)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("같은 건물과 주소의 운동만 시작시간 오름차순으로 반환한다") + void 같은_건물과_주소의_운동만_시작시간_오름차순으로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", targetBuildingName) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-05-10")) + .andExpect(jsonPath("$.data.dayOfWeek").value("SUNDAY")) + .andExpect(jsonPath("$.data.buildingName").value(targetBuildingName)) + .andExpect(jsonPath("$.data.exercises.length()").value(2)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(morningExercise.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].profileImageUrl").isEmpty()) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.exercises[0].startTime").value("09:00:00")) + .andExpect(jsonPath("$.data.exercises[0].endTime").value("11:00:00")) + .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(eveningExercise.getId())) + .andExpect(jsonPath("$.data.exercises[1].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.exercises[1].startTime").value("19:00:00")) + .andExpect(jsonPath("$.data.exercises[1].endTime").value("21:00:00")); + } + + @Test + @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다") + void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", "없는 건물") + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-05-10")) + .andExpect(jsonPath("$.data.dayOfWeek").value("SUNDAY")) + .andExpect(jsonPath("$.data.buildingName").value("없는 건물")) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", targetBuildingName) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("buildingName이 없으면 400을 반환한다") + void buildingName이_없으면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("streetAddr이 없으면 400을 반환한다") + void streetAddr이_없으면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", targetBuildingName)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400을 반환한다") + void 날짜_형식이_잘못되면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", "invalid-date") + .param("buildingName", targetBuildingName) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isBadRequest()); + } + } + + private Exercise saveBuildingExercise(String buildingName, String streetAddr, + LocalDate date, LocalTime startTime, LocalTime endTime) { + Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(buildingExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr)); + ReflectionTestUtils.setField(buildingExercise, "startTime", startTime); + ReflectionTestUtils.setField(buildingExercise, "endTime", endTime); + return exerciseRepository.save(buildingExercise); + } + } + + @Nested + @DisplayName("GET /api/buildings/map/monthly - 월간 운동 건물 지도 데이터 조회") + class GetMonthlyExerciseBuildings { + + private final LocalDate targetDate = LocalDate.of(2026, 4, 15); + private Member memberWithoutMainAddr; + + @BeforeEach + void setUp() { + saveMemberAddr(normalMember, "서울특별시", "강남구", "역삼동", + "서울특별시 강남구 테헤란로 1", "대표주소", 37.5, 127.0, true); + + memberWithoutMainAddr = memberRepository.save( + MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.B, 1010L, LocalDate.of(2000, 1, 1))); + saveMemberAddr(memberWithoutMainAddr, "서울특별시", "송파구", "잠실동", + "서울특별시 송파구 올림픽로 1", "보조주소", 37.514, 127.102, false); + + saveMapExercise(LocalDate.of(2026, 4, 3), "A빌딩", "서울특별시 강남구 테헤란로 10", + 37.5005, 127.0005, LocalTime.of(9, 0)); + saveMapExercise(LocalDate.of(2026, 4, 3), "A빌딩", "서울특별시 강남구 테헤란로 10", + 37.5005, 127.0005, LocalTime.of(19, 0)); + saveMapExercise(LocalDate.of(2026, 4, 3), "B빌딩", "서울특별시 강남구 테헤란로 20", + 37.501, 127.001, LocalTime.of(13, 0)); + saveMapExercise(LocalDate.of(2026, 4, 4), "A빌딩", "서울특별시 강남구 테헤란로 10", + 37.5005, 127.0005, LocalTime.of(10, 0)); + saveMapExercise(LocalDate.of(2026, 4, 5), "반경밖빌딩", "부산광역시 해운대구 센텀로 1", + 35.17, 129.13, LocalTime.of(12, 0)); + saveMapExercise(LocalDate.of(2026, 4, 6), "부산빌딩", "부산광역시 해운대구 센텀로 2", + 35.1705, 129.1305, LocalTime.of(14, 0)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("기본 요청은 현재 월과 대표주소 중심 좌표를 사용한다") + void 기본_요청은_현재_월과_대표주소_중심_좌표를_사용한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(LocalDate.now().getYear())) + .andExpect(jsonPath("$.data.month").value(LocalDate.now().getMonthValue())) + .andExpect(jsonPath("$.data.centerLatitude").value(37.5)) + .andExpect(jsonPath("$.data.centerLongitude").value(127.0)) + .andExpect(jsonPath("$.data.radiusKm").value(3.0)) + .andExpect(jsonPath("$.data.buildings").isMap()); + } + + @Test + @DisplayName("명시 날짜와 좌표로 조회하면 날짜별 건물 지도를 dedupe하여 반환한다") + void 명시_날짜와_좌표로_조회하면_날짜별_건물_지도를_dedupe하여_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "37.5") + .param("longitude", "127.0") + .param("radiusKm", "3.9")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(2026)) + .andExpect(jsonPath("$.data.month").value(4)) + .andExpect(jsonPath("$.data.centerLatitude").value(37.5)) + .andExpect(jsonPath("$.data.centerLongitude").value(127.0)) + .andExpect(jsonPath("$.data.radiusKm").value(3.9)) + .andExpect(jsonPath("$.data.buildings['2026-04-03'].length()").value(2)) + .andExpect(jsonPath("$.data.buildings['2026-04-03'][*].buildingName", containsInAnyOrder("A빌딩", "B빌딩"))) + .andExpect(jsonPath("$.data.buildings['2026-04-04'].length()").value(1)) + .andExpect(jsonPath("$.data.buildings['2026-04-04'][0].buildingName").value("A빌딩")) + .andExpect(jsonPath("$.data.buildings['2026-04-05']").doesNotExist()); + } + + @Test + @DisplayName("명시 좌표는 대표주소 대신 응답 중심 좌표로 반영된다") + void 명시_좌표는_대표주소_대신_응답_중심_좌표로_반영된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "35.17") + .param("longitude", "129.13") + .param("radiusKm", "5.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.centerLatitude").value(35.17)) + .andExpect(jsonPath("$.data.centerLongitude").value(129.13)) + .andExpect(jsonPath("$.data.radiusKm").value(5.0)) + .andExpect(jsonPath("$.data.buildings['2026-04-06'][0].buildingName").value("부산빌딩")); + } + + @Test + @DisplayName("반경 내 운동이 없으면 빈 buildings를 반환한다") + void 반경_내_운동이_없으면_빈_buildings를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "36.0") + .param("longitude", "128.0") + .param("radiusKm", "1.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(2026)) + .andExpect(jsonPath("$.data.month").value(4)) + .andExpect(jsonPath("$.data.buildings").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("대표주소가 없으면 에러를 반환한다") + void 대표주소가_없으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + + @Test + @DisplayName("대표주소가 없으면 명시 좌표가 있어도 에러를 반환한다") + void 대표주소가_없으면_명시_좌표가_있어도_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "37.5") + .param("longitude", "127.0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + + @Test + @DisplayName("위도만 주면 에러를 반환한다") + void 위도만_주면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "37.5")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_LOCATION_INFO.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_LOCATION_INFO.getMessage())); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400을 반환한다") + void 날짜_형식이_잘못되면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", "invalid-date")) + .andExpect(status().isBadRequest()); + } + } + + private MemberAddr saveMemberAddr(Member member, String addr1, String addr2, String addr3, + String streetAddr, String buildingName, + double latitude, double longitude, boolean isMain) { + return memberAddrRepository.save(MemberAddr.builder() + .member(member) + .addr1(addr1) + .addr2(addr2) + .addr3(addr3) + .streetAddr(streetAddr) + .buildingName(buildingName) + .latitude(latitude) + .longitude(longitude) + .isMain(isMain) + .build()); + } + + private Exercise saveMapExercise(LocalDate date, String buildingName, String streetAddr, + double latitude, double longitude, LocalTime startTime) { + Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(exercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude)); + ReflectionTestUtils.setField(exercise, "startTime", startTime); + return exerciseRepository.save(exercise); + } + } + + @Nested + @DisplayName("GET /api/exercises/recommendations/calendar - 사용자 추천 운동 캘린더 조회") + class GetRecommendedExerciseCalendar { + + private Member recommendationMember; + private Member memberWithoutMainAddr; + private Party filteredParty; + private LocalDate startDate; + private LocalDate endDate; + private Exercise filteredEarlyExercise; + private Exercise filteredPopularExercise; + + @BeforeEach + void setUp() { + recommendationMember = memberRepository.save( + MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 1201L, LocalDate.of(1995, 6, 15))); + memberAddrRepository.save(MemberAddrFixture.createMainAddr(recommendationMember)); + + memberWithoutMainAddr = memberRepository.save( + MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 1202L, LocalDate.of(1995, 6, 15))); + + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + PartyAddr filteredAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "송파구")); + filteredParty = PartyFixture.createParty("필터 모임", manager.getId(), filteredAddr); + ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE); + ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON); + filteredParty = partyRepository.save(filteredParty); + filteredParty.addLevel(Gender.MALE, Level.B); + filteredParty = partyRepository.save(filteredParty); + memberPartyRepository.save(MemberFixture.createMemberParty(filteredParty, manager, Role.party_MANAGER)); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 4, 5); + + filteredEarlyExercise = saveRecommendableExercise(filteredParty, LocalDate.of(2026, 3, 25), + 37.51, 127.01, "필터 이른 체육관", LocalTime.of(9, 0), LocalTime.of(11, 0)); + filteredPopularExercise = saveRecommendableExercise(filteredParty, LocalDate.of(2026, 3, 25), + 37.52, 127.02, "필터 인기 체육관", LocalTime.of(18, 0), LocalTime.of(20, 0)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(manager, filteredPopularExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, filteredPopularExercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(recommendationMember) + .exercise(filteredPopularExercise) + .build()); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("기본 요청은 기본 기간의 콕플 추천 캘린더를 거리순으로 반환한다") + void 기본_요청은_기본_기간의_콕플_추천_캘린더를_거리순으로_반환한다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise nearExercise = saveRecommendableExercise(party, defaultExerciseDate, + 37.5, 127.0, "가까운 체육관", LocalTime.of(11, 0), LocalTime.of(13, 0)); + Exercise farExercise = saveRecommendableExercise(party, defaultExerciseDate, + 35.1, 129.1, "먼 체육관", LocalTime.of(9, 0), LocalTime.of(11, 0)); + + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(nearExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].buildingName").value("가까운 체육관")) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].distance").value(0.0)) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[1].exerciseId").value(farExercise.getId())); + } + + @Test + @DisplayName("필터 추천 최신순은 필터 조건에 맞는 운동만 시간순으로 반환한다") + void 필터_추천_최신순은_필터_조건에_맞는_운동만_시간순으로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("isCockpleRecommend", "false") + .param("levels", "B") + .param("participationTypes", "SINGLE") + .param("activityTimes", "AFTERNOON") + .param("sortType", "LATEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(filteredEarlyExercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(filteredParty.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("필터 모임")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].distance").value(nullValue())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[1].exerciseId").value(filteredPopularExercise.getId())); + } + + @Test + @DisplayName("필터 추천 인기순은 참가자 수가 많은 운동을 먼저 반환한다") + void 필터_추천_인기순은_참가자_수가_많은_운동을_먼저_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("isCockpleRecommend", "false") + .param("levels", "B") + .param("participationTypes", "SINGLE") + .param("activityTimes", "AFTERNOON") + .param("sortType", "POPULARITY")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(filteredPopularExercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[1].exerciseId").value(filteredEarlyExercise.getId())); + } + + @Test + @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다") + void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "2030-01-05") + .param("endDate", "2030-01-11")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2030-01-05")) + .andExpect(jsonPath("$.data.endDate").value("2030-01-11")) + .andExpect(jsonPath("$.data.weeks[0].days[0].date").value("2029-12-31")) + .andExpect(jsonPath("$.data.weeks[0].days[5].date").value("2030-01-05")) + .andExpect(jsonPath("$.data.weeks[0].days[5].exercises").isEmpty()); + } + + @Test + @DisplayName("startDate만 주어져도 기본 기간이 적용된다") + void startDate만_주어져도_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = saveRecommendableExercise(party, defaultExerciseDate, + 37.5, 127.0, "기본기간 체육관", LocalTime.of(10, 0), LocalTime.of(12, 0)); + + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "2026-03-25")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())); + } + + @Test + @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다") + void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "2026-04-05") + .param("endDate", "2026-03-23")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("대표 주소가 없으면 에러를 반환한다") + void 대표_주소가_없으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400을 반환한다") + void 날짜_형식이_잘못되면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "invalid-date") + .param("endDate", endDate.toString())) + .andExpect(status().isBadRequest()); + } + } + + private Exercise saveRecommendableExercise(Party exerciseParty, LocalDate date, + double latitude, double longitude, + String buildingName, LocalTime startTime, LocalTime endTime) { + Exercise exercise = ExerciseFixture.createRecommendableExercise( + exerciseParty, date, latitude, longitude, buildingName); + ReflectionTestUtils.setField(exercise, "startTime", startTime); + ReflectionTestUtils.setField(exercise, "endTime", endTime); + return exerciseRepository.save(exercise); + } + } + +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java new file mode 100644 index 000000000..cb51d226d --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java @@ -0,0 +1,281 @@ +package umc.cockple.demo.domain.exercise.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ExerciseRecommendationIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired MemberAddrRepository memberAddrRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired GuestRepository guestRepository; + + // 조회 대상 회원 (모임 외부인, 추천 운동 수신 대상) + private Member outsider; + // 모임장 (모임 소속, 추천에서 제외) + private Member manager; + private Party party; + + @BeforeEach + void setUp() { + // 추천 대상 회원: MALE, Level.A, 1995년생 + outsider = memberRepository.save( + MemberFixture.createMember("외부회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15))); + + // 대표 주소 저장 (서울 강남구, lat=37.5, lon=127.0) + MemberAddr addr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(outsider)); + + // 모임장 (모임 소속) + manager = memberRepository.save( + MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1002L, LocalDate.of(1995, 1, 1))); + + // 모임 생성 (minBirthYear=1990, maxBirthYear=2005) + PartyAddr partyAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), partyAddr)); + + // PartyLevel 추가: MALE A급 (cascade로 저장됨) + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + // 모임장을 모임 멤버로 등록 + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); + } + + @AfterEach + void tearDown() { + guestRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + @Nested + @DisplayName("GET /api/exercises/recommendations - 사용자 추천 운동 조회") + class GetRecommendedExercises { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("추천 운동이 존재하면 200 OK와 운동 목록을 반환한다") + void 추천_운동이_존재하면_목록을_반환한다() throws Exception { + // given + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(1)) + .andExpect(jsonPath("$.data.exercises").isArray()) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(false)); + } + + @Test + @DisplayName("응답 필드가 모두 존재한다") + void 응답_필드가_모두_존재한다() throws Exception { + // given + Exercise saved = exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "필드확인 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(saved.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").exists()) + .andExpect(jsonPath("$.data.exercises[0].date").exists()) + .andExpect(jsonPath("$.data.exercises[0].dayOfWeek").exists()) + .andExpect(jsonPath("$.data.exercises[0].startTime").exists()) + .andExpect(jsonPath("$.data.exercises[0].buildingName").exists()) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").exists()); + } + + @Test + @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다") + void 추천_운동이_없으면_빈_목록을_반환한다() throws Exception { + // given - 운동 없음 + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + + @Test + @DisplayName("이미 소속된 모임의 운동은 추천되지 않는다") + void 소속된_모임의_운동은_추천되지_않는다() throws Exception { + // given - outsider를 모임에 가입시킴 + memberPartyRepository.save( + MemberFixture.createMemberParty(party, outsider, Role.party_MEMBER)); + + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then - 소속 모임이므로 추천 목록에서 제외 + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("outsideGuestAccept=false인 운동은 추천되지 않는다") + void outsideGuestAccept_false_운동은_추천되지_않는다() throws Exception { + // given - outsideGuestAccept=false 운동 + exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(3))); + // createExerciseWithAddr는 outsideGuestAccept=false + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("이미 지난 운동은 추천되지 않는다") + void 지난_운동은_추천되지_않는다() throws Exception { + // given - 과거 날짜 운동 + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().minusDays(1), 37.5, 127.0, "과거 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("이미 참여한 운동은 추천되지 않는다") + void 이미_참여한_운동은_추천되지_않는다() throws Exception { + // given + Exercise ex = exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "참여완료 체육관")); + memberExerciseRepository.save(MemberFixture.createMemberExercise(outsider, ex)); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("거리 가까운 순으로 정렬되어 반환된다") + void 거리_가까운_순으로_정렬된다() throws Exception { + // given + // 가까운 운동 (강남, lat=37.5 lon=127.0 - outsider 대표주소와 동일) + Exercise nearExercise = exerciseRepository.save( + ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(5), 37.5, 127.0, "가까운 체육관")); + // 먼 운동 (부산 해운대, lat=35.1 lon=129.1) + Exercise farExercise = exerciseRepository.save( + ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(1), 35.1, 129.1, "먼 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then - 날짜가 늦어도 가까운 운동이 먼저 + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(2)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(nearExercise.getId())) + .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(farExercise.getId())); + } + + @Test + @DisplayName("최대 10개까지만 반환된다") + void 최대_10개까지만_반환된다() throws Exception { + // given - 12개 운동 저장 + for (int i = 1; i <= 12; i++) { + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(i), 37.5, 127.0, "체육관" + i)); + } + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(10)) + .andExpect(jsonPath("$.data.exercises.length()").value(10)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("대표 주소가 없으면 400 에러를 반환한다") + void 대표_주소가_없으면_400_에러를_반환한다() throws Exception { + // given - 대표 주소 없는 회원 + Member noAddrMember = memberRepository.save( + MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 9999L, + LocalDate.of(1995, 1, 1))); + // MemberAddr 저장 안 함 + + SecurityContextHelper.setAuthentication(noAddrMember.getId(), noAddrMember.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 59c0863e9..117fd8d8f 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -8,45 +8,78 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberExercise; import umc.cockple.demo.domain.member.domain.MemberParty; -import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.ActivityTime; +import umc.cockple.demo.domain.party.enums.ParticipationType; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; import umc.cockple.demo.domain.party.repository.PartyRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.ExerciseCalendarTestHelper; import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @DisplayName("ExerciseQueryService") @@ -85,15 +118,7 @@ void setUp() { exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1)); ReflectionTestUtils.setField(exercise, "id", 100L); - ExerciseAddr exerciseAddr = ExerciseAddr.builder() - .addr1("서울특별시") - .addr2("강남구") - .streetAddr("서울특별시 강남구 테헤란로 1") - .buildingName("테스트 체육관") - .latitude(37.5) - .longitude(127.0) - .build(); - ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr); + ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr()); } @Nested @@ -129,8 +154,35 @@ class Success { } @Test - @DisplayName("일반_멤버면_isManager_false로_반환된다") - void 일반_멤버면_isManager_false로_반환된다() { + @DisplayName("부모임장이_조회하면_isManager_false로_반환된다") + void 부모임장이_조회하면_isManager_false로_반환된다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 2003L); + ReflectionTestUtils.setField(subManager, "id", 21L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(subManager.getId())) + .willReturn(Optional.of(subManager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), subManager.getId(), Role.party_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + exercise.getId(), subManager.getId()); + + // then + assertThat(response.isManager()).isFalse(); + } + + @Test + @DisplayName("모임_일반_멤버여도_isManager_false로_반환된다") + void 모임_일반_멤버여도_isManager_false로_반환된다() { // given Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 2002L); ReflectionTestUtils.setField(normalMember, "id", 2L); @@ -155,22 +207,42 @@ class Success { assertThat(response.isManager()).isFalse(); } + @Test + @DisplayName("모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다") + void 모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다() { + // given + Member outsider = MemberFixture.createMember("외부회원", Gender.MALE, Level.C, 3003L); + ReflectionTestUtils.setField(outsider, "id", 3L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(outsider.getId())) + .willReturn(Optional.of(outsider)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), outsider.getId(), Role.party_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + exercise.getId(), outsider.getId()); + + // then + assertThat(response.isManager()).isFalse(); + assertThat(response.info().buildingName()).isEqualTo("테스트 체육관"); + } + @Test @DisplayName("탈퇴_회원은_isWithdrawn_true로_반환된다") void 탈퇴_회원은_isWithdrawn_true로_반환된다() { // given - Member withdrawnMember = Member.builder() - .memberName("탈퇴회원") - .nickname("탈퇴닉네임") - .gender(Gender.MALE) - .level(Level.C) - .isActive(MemberStatus.INACTIVE) - .socialId(9999L) - .build(); + Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 9999L); ReflectionTestUtils.setField(withdrawnMember, "id", 99L); MemberExercise memberExercise = MemberFixture.createMemberExercise(withdrawnMember, exercise); - ReflectionTestUtils.setField(memberExercise, "createdAt", LocalDateTime.now()); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -205,7 +277,6 @@ class Success { ReflectionTestUtils.setField(activeMember, "id", 2L); MemberExercise memberExercise = MemberFixture.createMemberExercise(activeMember, exercise); - ReflectionTestUtils.setField(memberExercise, "createdAt", LocalDateTime.now()); MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.party_MEMBER); @@ -238,6 +309,9 @@ class Success { @DisplayName("게스트는_isWithdrawn_false로_반환된다") void 게스트는_isWithdrawn_false로_반환된다() { // given + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 70L); + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); given(memberRepository.findById(manager.getId())) @@ -245,10 +319,12 @@ class Success { given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) .willReturn(List.of()); given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); + .willReturn(List.of(guest)); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( party.getId(), manager.getId(), Role.party_MANAGER)) .willReturn(true); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); // when ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( @@ -256,7 +332,78 @@ class Success { // then List participants = response.participants().list(); - assertThat(participants).isEmpty(); + assertThat(participants).hasSize(1); + assertThat(participants.get(0).isWithdrawn()).isFalse(); + assertThat(participants.get(0).partyPosition()).isNull(); + } + + @Test + @DisplayName("참가자_유형별_partyPosition이_올바르게_반환된다") + void 참가자_유형별_partyPosition이_올바르게_반환된다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 5003L); + ReflectionTestUtils.setField(subManager, "id", 31L); + + Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 5004L); + ReflectionTestUtils.setField(normalMember, "id", 32L); + + Member outsider = MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 5005L); + ReflectionTestUtils.setField(outsider, "id", 33L); + + MemberExercise managerExercise = MemberFixture.createMemberExercise(manager, exercise); + ReflectionTestUtils.setField(managerExercise, "createdAt", LocalDateTime.now().minusMinutes(5)); + + MemberExercise subManagerExercise = MemberFixture.createMemberExercise(subManager, exercise); + ReflectionTestUtils.setField(subManagerExercise, "createdAt", LocalDateTime.now().minusMinutes(4)); + + MemberExercise normalMemberExercise = MemberFixture.createMemberExercise(normalMember, exercise); + ReflectionTestUtils.setField(normalMemberExercise, "createdAt", LocalDateTime.now().minusMinutes(3)); + + MemberExercise outsiderExercise = MemberFixture.createExternalMemberExercise(outsider, exercise); + ReflectionTestUtils.setField(outsiderExercise, "createdAt", LocalDateTime.now().minusMinutes(2)); + + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 71L); + ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now().minusMinutes(1)); + + MemberParty managerParty = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER); + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER); + MemberParty memberParty = MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(managerExercise, subManagerExercise, normalMemberExercise, outsiderExercise)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(guest)); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.party_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(manager.getId(), subManager.getId(), normalMember.getId(), outsider.getId()))) + .willReturn(List.of(managerParty, subManagerParty, memberParty)); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); + + // when + ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participants().list()) + .extracting( + ExerciseDetailDTO.ParticipantInfo::name, + ExerciseDetailDTO.ParticipantInfo::participantType, + ExerciseDetailDTO.ParticipantInfo::partyPosition) + .containsExactly( + tuple("모임장", "PARTY_MEMBER", "party_MANAGER"), + tuple("부모임장", "PARTY_MEMBER", "party_SUBMANAGER"), + tuple("일반멤버", "PARTY_MEMBER", "party_MEMBER"), + tuple("외부회원", "EXTERNAL_PARTICIPANT", null), + tuple("게스트", "GUEST", null) + ); } @Test @@ -311,7 +458,6 @@ class Success { // given Guest guest = GuestFixture.createGuest(exercise, manager.getId()); ReflectionTestUtils.setField(guest, "id", 50L); - ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now()); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -509,4 +655,1702 @@ class Failure { } } } + + @Nested + @DisplayName("getExerciseForEdit") + class GetExerciseForEdit { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("운동 수정용 상세 정보의 모든 필드가 올바르게 반환된다") + void 운동_수정용_상세_정보의_모든_필드가_올바르게_반환된다() { + // given + LocalDate targetDate = LocalDate.of(2026, 3, 24); + Exercise exerciseForEdit = ExerciseFixture.createExerciseForEdit(party, targetDate); + ReflectionTestUtils.setField(exerciseForEdit, "id", 101L); + + given(exerciseRepository.findExerciseWithBasicInfo(exerciseForEdit.getId())) + .willReturn(Optional.of(exerciseForEdit)); + + // when + ExerciseEditDetailDTO.Response response = exerciseQueryService.getExerciseForEdit( + exerciseForEdit.getId(), manager.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.buildingName()).isEqualTo("테스트 체육관"); + assertThat(response.roadAddress()).isEqualTo("서울특별시 강남구 테헤란로 1"); + assertThat(response.latitude()).isEqualTo(37.5); + assertThat(response.longitude()).isEqualTo(127.0); + assertThat(response.startTime()).isEqualTo(LocalTime.of(10, 0)); + assertThat(response.endTime()).isEqualTo(LocalTime.of(12, 30)); + assertThat(response.maxCapacity()).isEqualTo(18); + assertThat(response.allowMemberGuestsInvitation()).isTrue(); + assertThat(response.allowExternalGuests()).isFalse(); + assertThat(response.notice()).isEqualTo("수정 공지사항"); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseForEdit(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("getMyInvitedGuests") + class GetMyInvitedGuests { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다") + void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() { + // given + ReflectionTestUtils.setField(exercise, "maxCapacity", 1); + + Guest myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE); + ReflectionTestUtils.setField(myFirstGuest, "id", 201L); + ReflectionTestUtils.setField(myFirstGuest, "createdAt", LocalDateTime.now().minusMinutes(3)); + + Guest otherInvitedGuest = GuestFixture.createGuest(exercise, 2L, "다른사람게스트", Gender.MALE); + ReflectionTestUtils.setField(otherInvitedGuest, "id", 202L); + ReflectionTestUtils.setField(otherInvitedGuest, "createdAt", LocalDateTime.now().minusMinutes(2)); + + Guest mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE); + ReflectionTestUtils.setField(mySecondGuest, "id", 203L); + ReflectionTestUtils.setField(mySecondGuest, "createdAt", LocalDateTime.now().minusMinutes(1)); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) + .willReturn(List.of(myFirstGuest, mySecondGuest)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(myFirstGuest, otherInvitedGuest, mySecondGuest)); + + // when + ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( + exercise.getId(), manager.getId()); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.maleCount()).isEqualTo(1); + assertThat(response.femaleCount()).isEqualTo(1); + assertThat(response.list()) + .extracting( + ExerciseMyGuestListDTO.GuestInfo::guestId, + ExerciseMyGuestListDTO.GuestInfo::isWaiting, + ExerciseMyGuestListDTO.GuestInfo::participantNumber, + ExerciseMyGuestListDTO.GuestInfo::name, + ExerciseMyGuestListDTO.GuestInfo::gender, + ExerciseMyGuestListDTO.GuestInfo::level, + ExerciseMyGuestListDTO.GuestInfo::inviterName + ) + .containsExactly( + tuple(201L, false, 1, "내게스트1", Gender.MALE, Level.B, manager.getMemberName()), + tuple(203L, true, 2, "내게스트2", Gender.FEMALE, Level.B, manager.getMemberName()) + ); + } + + @Test + @DisplayName("초대한_게스트가_없으면_빈_응답을_반환한다") + void 초대한_게스트가_없으면_빈_응답을_반환한다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) + .willReturn(List.of()); + + // when + ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( + exercise.getId(), manager.getId()); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.maleCount()).isZero(); + assertThat(response.femaleCount()).isZero(); + assertThat(response.list()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyInvitedGuests(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyInvitedGuests(exercise.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("getPartyExerciseCalendar") + class GetPartyExerciseCalendar { + + private Member partyMember; + private Member outsiderMember; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + partyMember = MemberFixture.createMember("파티멤버", Gender.FEMALE, Level.B, 3001L); + ReflectionTestUtils.setField(partyMember, "id", 2L); + + outsiderMember = MemberFixture.createMember("외부멤버", Gender.MALE, Level.C, 3002L); + ReflectionTestUtils.setField(outsiderMember, "id", 3L); + + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + ReflectionTestUtils.setField(exercise, "date", LocalDate.of(2026, 3, 24)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임 운동 캘린더를 주차별_일자별로 반환한다") + void 모임_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) + .willReturn(true); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) + .willReturn(List.of(exercise)); + given(exerciseRepository.findExerciseParticipantCounts(party.getId(), startDate, endDate)) + .willReturn(java.util.Collections.singletonList(new Object[]{exercise.getId(), 2})); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + partyMember.getId(), List.of(exercise.getId()))) + .willReturn(List.of(exercise.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds( + partyMember.getId(), List.of(exercise.getId()))) + .willReturn(List.of(exercise.getId())); + + // when + PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.isMember()).isTrue(); + assertThat(response.partyName()).isEqualTo(party.getPartyName()); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(1).date()) + .isEqualTo(LocalDate.of(2026, 3, 24)); + assertThat(response.weeks().get(0).days().get(1).exercises()) + .extracting( + PartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + PartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, + PartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + PartyExerciseCalendarDTO.ExerciseCalendarItem::currentParticipants, + PartyExerciseCalendarDTO.ExerciseCalendarItem::maxCapacity, + PartyExerciseCalendarDTO.ExerciseCalendarItem::isParticipating) + .containsExactly(tuple(exercise.getId(), true, "테스트 체육관", 2, 10, true)); + } + + @Test + @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(outsiderMember.getId())) + .willReturn(Optional.of(outsiderMember)); + given(memberPartyRepository.existsByPartyAndMember(party, outsiderMember)) + .willReturn(false); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) + .willReturn(List.of()); + + // when + PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + party.getId(), outsiderMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.isMember()).isFalse(); + assertThat(response.partyName()).isEqualTo(party.getPartyName()); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) + .willReturn(true); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.isMember()).isTrue(); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") + void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, null)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + @Test + @DisplayName("삭제된 모임이면 예외를 던진다") + void 삭제된_모임이면_예외를_던진다() { + // given + ReflectionTestUtils.setField(party, "status", PartyStatus.INACTIVE); + + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, endDate)) + .isInstanceOf(PartyException.class) + .hasFieldOrPropertyWithValue("code", PartyErrorCode.PARTY_IS_DELETED); + } + } + } + + @Nested + @DisplayName("getMyExerciseCalendar") + class GetMyExerciseCalendar { + + private Member calendarMember; + private LocalDate startDate; + private LocalDate endDate; + private Exercise myExercise; + + @BeforeEach + void setUp() { + calendarMember = MemberFixture.createMember("캘린더멤버", Gender.FEMALE, Level.B, 4001L); + ReflectionTestUtils.setField(calendarMember, "id", 4L); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + myExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); + ReflectionTestUtils.setField(myExercise, "id", 200L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 운동 캘린더를 주차별_일자별로 반환한다") + void 내_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) + .willReturn(List.of(myExercise)); + + // when + MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(2).date()) + .isEqualTo(LocalDate.of(2026, 3, 25)); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + MyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + MyExerciseCalendarDTO.ExerciseCalendarItem::partyId, + MyExerciseCalendarDTO.ExerciseCalendarItem::partyName, + MyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + MyExerciseCalendarDTO.ExerciseCalendarItem::startTime, + MyExerciseCalendarDTO.ExerciseCalendarItem::endTime, + MyExerciseCalendarDTO.ExerciseCalendarItem::profileImageUrl) + .containsExactly(tuple(200L, 10L, "테스트 모임", "테스트 체육관", LocalTime.of(10, 0), null, null)); + } + + @Test + @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) + .willReturn(List.of()); + + // when + MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar(999L, startDate, endDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") + void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, null)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + @Test + @DisplayName("시작일이 종료일과 같거나 늦으면 예외를 던진다") + void 시작일이_종료일과_같거나_늦으면_예외를_던진다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), endDate, startDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INVALID_DATE_RANGE); + } + } + } + + @Nested + @DisplayName("getMyPartyExercise") + class GetMyPartyExercise { + + private Member partyMember; + private Exercise firstUpcomingExercise; + private Exercise secondUpcomingExercise; + + @BeforeEach + void setUp() { + partyMember = MemberFixture.createMember("내모임멤버", Gender.MALE, Level.B, 5001L); + ReflectionTestUtils.setField(partyMember, "id", 5L); + + firstUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 1)); + ReflectionTestUtils.setField(firstUpcomingExercise, "id", 301L); + + secondUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 2)); + ReflectionTestUtils.setField(secondUpcomingExercise, "id", 302L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 모임의 예정된 운동 목록을 반환한다") + void 내_모임의_예정된_운동_목록을_반환한다() { + // given + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findRecentExercisesByPartyIds(eq(List.of(party.getId())), argThat( + (org.springframework.data.domain.Pageable pageable) -> pageable.getPageNumber() == 0 && pageable.getPageSize() == 6))) + .willReturn(List.of(firstUpcomingExercise, secondUpcomingExercise)); + + // when + MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(partyMember.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(2); + assertThat(response.exercises()) + .extracting( + MyPartyExerciseDTO.Exercises::exerciseId, + MyPartyExerciseDTO.Exercises::partyId, + MyPartyExerciseDTO.Exercises::partyName, + MyPartyExerciseDTO.Exercises::buildingName, + MyPartyExerciseDTO.Exercises::date, + MyPartyExerciseDTO.Exercises::dayOfWeek, + MyPartyExerciseDTO.Exercises::startTime, + MyPartyExerciseDTO.Exercises::profileImageUrl) + .containsExactly( + tuple(301L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 1), "WEDNESDAY", LocalTime.of(10, 0), null), + tuple(302L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 2), "THURSDAY", LocalTime.of(10, 0), null) + ); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 응답을 반환한다") + void 속한_모임이_없으면_빈_응답을_반환한다() { + // given + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) + .willReturn(List.of()); + + // when + MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(partyMember.getId()); + + // then + assertThat(response.totalExercises()).isZero(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository, never()).findRecentExercisesByPartyIds(any(), any()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyPartyExercise(999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("getMyPartyExerciseCalendar") + class GetMyPartyExerciseCalendar { + + private Member calendarMember; + private Exercise calendarExercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + calendarMember = MemberFixture.createMember("내모임캘린더멤버", Gender.FEMALE, Level.B, 6001L); + ReflectionTestUtils.setField(calendarMember, "id", 6L); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + calendarExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); + ReflectionTestUtils.setField(calendarExercise, "id", 400L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 모임 운동 캘린더를 주차별_일자별로 반환한다") + void 내_모임_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of(calendarExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + calendarMember.getId(), List.of(calendarExercise.getId()))) + .willReturn(List.of()); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(calendarExercise.getId()), startDate, endDate)) + .willReturn(Collections.singletonList(new Object[]{calendarExercise.getId(), 3})); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(2).date()) + .isEqualTo(LocalDate.of(2026, 3, 25)); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyId, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyName, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::nowCapacity) + .containsExactly(tuple(400L, 10L, "테스트 모임", "테스트 체육관", false, 3)); + } + + @Test + @DisplayName("북마크한 운동은 isBookmarked가 true로 반환된다") + void 북마크한_운동은_isBookmarked가_true로_반환된다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of(calendarExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + calendarMember.getId(), List.of(calendarExercise.getId()))) + .willReturn(List.of(calendarExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(calendarExercise.getId()), startDate, endDate)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.weeks().get(0).days().get(2).exercises().get(0).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다") + void 속한_모임이_없으면_빈_캘린더를_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + verify(exerciseRepository, never()).findByPartyIdsAndDateRange(any(), any(), any()); + } + + @Test + @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyPartyExerciseCalendar( + 999L, MyPartyExerciseOrderType.LATEST, startDate, endDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + } + + @Nested + @DisplayName("getMyExercises") + class GetMyExercises { + + private Member myExerciseMember; + private Exercise completedExercise; + private Exercise upcomingExercise; + private Exercise futureLatestExercise; + private Pageable firstPage; + + @BeforeEach + void setUp() { + myExerciseMember = MemberFixture.createMember("내참여운동멤버", Gender.MALE, Level.B, 7001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(myExerciseMember, "id", 7L); + + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + + completedExercise = createMyExercise(701L, LocalDate.of(2024, 1, 5), + LocalTime.of(9, 0), LocalTime.of(11, 0), 18, false); + upcomingExercise = createMyExercise(702L, LocalDate.of(2099, 1, 3), + LocalTime.of(18, 0), null, 12, true); + futureLatestExercise = createMyExercise(703L, LocalDate.of(2099, 1, 10), + LocalTime.of(7, 30), LocalTime.of(9, 0), 20, true); + firstPage = PageRequest.of(0, 2); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("ALL 최신순은 전체 운동 리포지토리를 날짜 내림차순으로 호출한다") + void ALL_최신순은_전체_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); + verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); + } + + @Test + @DisplayName("UPCOMING 최신순은 예정 운동 리포지토리를 날짜 오름차순으로 호출한다") + void UPCOMING_최신순은_예정_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.LATEST, firstPage); + + // then + verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); + verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); + } + + @Test + @DisplayName("COMPLETED 최신순은 완료 운동 리포지토리를 날짜 내림차순으로 호출한다") + void COMPLETED_최신순은_완료_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.LATEST, firstPage); + + // then + verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); + verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); + verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("ALL 오래된순은 전체 운동 리포지토리를 날짜 오름차순으로 호출한다") + void ALL_오래된순은_전체_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("UPCOMING 오래된순은 예정 운동 리포지토리를 날짜 내림차순으로 호출한다") + void UPCOMING_오래된순은_예정_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("COMPLETED 오래된순은 완료 운동 리포지토리를 날짜 오름차순으로 호출한다") + void COMPLETED_오래된순은_완료_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("조회된 운동이 없으면 빈 응답을 반환한다") + void 조회된_운동이_없으면_빈_응답을_반환한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(emptySlice(firstPage)); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository, never()).findExerciseParticipantCountsByExerciseIds(any()); + verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); + } + + @Test + @DisplayName("조회 결과를 DTO 필드와 hasNext true로 매핑한다") + void 조회_결과를_DTO_필드와_hasNext_true로_매핑한다() { + // given + Slice exerciseSlice = sliceOf(List.of(futureLatestExercise, completedExercise), true, firstPage); + + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(exerciseSlice); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(futureLatestExercise.getId(), completedExercise.getId()))) + .willReturn(List.of( + new Object[]{futureLatestExercise.getId(), 3}, + new Object[]{completedExercise.getId(), 1} + )); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + myExerciseMember.getId(), List.of(futureLatestExercise.getId(), completedExercise.getId()))) + .willReturn(List.of(futureLatestExercise.getId())); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.exercises()) + .extracting( + MyExerciseListDTO.ExerciseItem::exerciseId, + MyExerciseListDTO.ExerciseItem::partyId, + MyExerciseListDTO.ExerciseItem::partyName, + MyExerciseListDTO.ExerciseItem::isBookmarked, + MyExerciseListDTO.ExerciseItem::date, + MyExerciseListDTO.ExerciseItem::dayOfWeek, + MyExerciseListDTO.ExerciseItem::buildingName, + MyExerciseListDTO.ExerciseItem::startTime, + MyExerciseListDTO.ExerciseItem::endTime, + MyExerciseListDTO.ExerciseItem::currentParticipants, + MyExerciseListDTO.ExerciseItem::maxCapacity, + MyExerciseListDTO.ExerciseItem::isCompleted, + MyExerciseListDTO.ExerciseItem::partyGuestInviteAccept + ) + .containsExactly( + tuple(703L, 10L, "테스트 모임", true, + LocalDate.of(2099, 1, 10), "SATURDAY", "테스트 체육관", + LocalTime.of(7, 30), LocalTime.of(9, 0), 3, 20, false, true), + tuple(701L, 10L, "테스트 모임", false, + LocalDate.of(2024, 1, 5), "FRIDAY", "테스트 체육관", + LocalTime.of(9, 0), LocalTime.of(11, 0), 1, 18, true, false) + ); + } + + @Test + @DisplayName("조회 결과를 hasNext false로 매핑한다") + void 조회_결과를_hasNext_false로_매핑한다() { + // given + Pageable secondPage = PageRequest.of(1, 1); + Slice exerciseSlice = sliceOf(List.of(upcomingExercise), false, secondPage); + + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(exerciseSlice); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds(List.of(upcomingExercise.getId()))) + .willReturn(List.of()); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + myExerciseMember.getId(), List.of(upcomingExercise.getId()))) + .willReturn(List.of()); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, secondPage); + + // then + assertThat(response.totalCount()).isEqualTo(1); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises().get(0).exerciseId()).isEqualTo(upcomingExercise.getId()); + assertThat(response.exercises().get(0).isCompleted()).isFalse(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExercises( + 999L, MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + + private Exercise createMyExercise(long id, LocalDate date, LocalTime startTime, + LocalTime endTime, int maxCapacity, boolean partyGuestAccept) { + Exercise createdExercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity); + ReflectionTestUtils.setField(createdExercise, "id", id); + ReflectionTestUtils.setField(createdExercise, "startTime", startTime); + ReflectionTestUtils.setField(createdExercise, "endTime", endTime); + ReflectionTestUtils.setField(createdExercise, "partyGuestAccept", partyGuestAccept); + return createdExercise; + } + + private Slice emptySlice(Pageable pageable) { + return new SliceImpl<>(List.of(), pageable, false); + } + + private Slice sliceOf(List exercises, boolean hasNext, Pageable pageable) { + return new SliceImpl<>(exercises, pageable, hasNext); + } + + private boolean matchesSort(Pageable pageable, Sort.Direction dateDirection, Sort.Direction timeDirection) { + if (pageable.getPageNumber() != firstPage.getPageNumber() || pageable.getPageSize() != firstPage.getPageSize()) { + return false; + } + + List orders = pageable.getSort().stream().toList(); + return orders.size() == 2 + && orders.get(0).getProperty().equals("date") + && orders.get(0).getDirection() == dateDirection + && orders.get(1).getProperty().equals("startTime") + && orders.get(1).getDirection() == timeDirection; + } + } + + @Nested + @DisplayName("getBuildingExerciseDetails") + class GetBuildingExerciseDetails { + + private Member buildingMember; + private LocalDate targetDate; + private String buildingName; + private String streetAddr; + + @BeforeEach + void setUp() { + buildingMember = MemberFixture.createMember("건물상세멤버", Gender.FEMALE, Level.B, 8001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(buildingMember, "id", 8L); + + targetDate = LocalDate.of(2026, 5, 10); + buildingName = "콕플 타워"; + streetAddr = "서울특별시 강남구 테헤란로 10"; + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다") + void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() { + // given + given(memberRepository.findById(buildingMember.getId())) + .willReturn(Optional.of(buildingMember)); + given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) + .willReturn(List.of()); + + // when + ExerciseBuildingDetailDTO.Response response = exerciseQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, buildingMember.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); + assertThat(response.buildingName()).isEqualTo(buildingName); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); + verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); + } + + @Test + @DisplayName("운동 목록을 순서와 북마크 상태를 유지해 DTO로 반환한다") + void 운동_목록을_순서와_북마크_상태를_유지해_DTO로_반환한다() { + // given + Exercise morningExercise = createBuildingExercise(801L, LocalTime.of(9, 0), LocalTime.of(11, 0)); + Exercise eveningExercise = createBuildingExercise(802L, LocalTime.of(19, 0), LocalTime.of(21, 0)); + + given(memberRepository.findById(buildingMember.getId())) + .willReturn(Optional.of(buildingMember)); + given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) + .willReturn(List.of(morningExercise, eveningExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId()))) + .willReturn(List.of(eveningExercise.getId())); + + // when + ExerciseBuildingDetailDTO.Response response = exerciseQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, buildingMember.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); + assertThat(response.buildingName()).isEqualTo(buildingName); + assertThat(response.exercises()) + .extracting( + ExerciseBuildingDetailDTO.ExerciseItem::exerciseId, + ExerciseBuildingDetailDTO.ExerciseItem::partyId, + ExerciseBuildingDetailDTO.ExerciseItem::partyName, + ExerciseBuildingDetailDTO.ExerciseItem::profileImageUrl, + ExerciseBuildingDetailDTO.ExerciseItem::isBookmarked, + ExerciseBuildingDetailDTO.ExerciseItem::startTime, + ExerciseBuildingDetailDTO.ExerciseItem::endTime + ) + .containsExactly( + tuple(801L, 10L, "테스트 모임", null, false, LocalTime.of(9, 0), LocalTime.of(11, 0)), + tuple(802L, 10L, "테스트 모임", null, true, LocalTime.of(19, 0), LocalTime.of(21, 0)) + ); + verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); + verify(exerciseBookmarkRepository).findAllExerciseIdsByMemberIdAndExerciseIds( + buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, 999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + + private Exercise createBuildingExercise(long id, LocalTime startTime, LocalTime endTime) { + Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, targetDate, 12); + ReflectionTestUtils.setField(buildingExercise, "id", id); + ReflectionTestUtils.setField(buildingExercise, "startTime", startTime); + ReflectionTestUtils.setField(buildingExercise, "endTime", endTime); + ReflectionTestUtils.setField(buildingExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr)); + return buildingExercise; + } + } + + @Nested + @DisplayName("getExerciseMapCalendarSummary") + class GetExerciseMapCalendarSummary { + + private Member mapMember; + private Member memberWithoutMainAddr; + private MemberAddr mainAddr; + private Double radiusKm; + + @BeforeEach + void setUp() { + mapMember = MemberFixture.createMember("지도멤버", Gender.MALE, Level.B, 9001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(mapMember, "id", 9L); + + mainAddr = MemberAddr.builder() + .member(mapMember) + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("서울특별시 강남구 테헤란로 1") + .buildingName("대표주소") + .latitude(37.501) + .longitude(127.039) + .isMain(true) + .build(); + ReflectionTestUtils.setField(mapMember, "addresses", List.of(mainAddr)); + + memberWithoutMainAddr = MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.C, 9002L, + LocalDate.of(2001, 1, 1)); + ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 10L); + MemberAddr subAddr = MemberAddr.builder() + .member(memberWithoutMainAddr) + .addr1("서울특별시") + .addr2("송파구") + .addr3("잠실동") + .streetAddr("서울특별시 송파구 올림픽로 1") + .buildingName("서브주소") + .latitude(37.514) + .longitude(127.102) + .isMain(false) + .build(); + ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(subAddr)); + + radiusKm = 3.9; + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("date가 null이면 현재 월 범위와 대표주소 좌표로 조회한다") + void date가_null이면_현재_월_범위와_대표주소_좌표로_조회한다() { + // given + YearMonth currentMonth = YearMonth.now(); + LocalDate monthStart = currentMonth.atDay(1); + LocalDate monthEnd = currentMonth.atEndOfMonth(); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + eq(monthStart), eq(monthEnd), eq(mainAddr.getLatitude()), eq(mainAddr.getLongitude()), eq(3))) + .willReturn(List.of()); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( + null, null, null, radiusKm, mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(currentMonth.getYear()); + assertThat(response.month()).isEqualTo(currentMonth.getMonthValue()); + assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); + assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings()).isEmpty(); + } + + @Test + @DisplayName("명시 좌표가 있으면 대표주소 대신 해당 좌표와 절삭 반경으로 조회한다") + void 명시_좌표가_있으면_대표주소_대신_해당_좌표와_절삭_반경으로_조회한다() { + // given + LocalDate targetDate = LocalDate.of(2026, 4, 15); + LocalDate monthStart = LocalDate.of(2026, 4, 1); + LocalDate monthEnd = LocalDate.of(2026, 4, 30); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + eq(monthStart), eq(monthEnd), eq(37.55), eq(127.11), eq(3))) + .willReturn(List.of()); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( + targetDate, 37.55, 127.11, radiusKm, mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(4); + assertThat(response.centerLatitude()).isEqualTo(37.55); + assertThat(response.centerLongitude()).isEqualTo(127.11); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings()).isEmpty(); + } + + @Test + @DisplayName("운동을 날짜별과 건물별로 그룹화해 응답을 만든다") + void 운동을_날짜별과_건물별로_그룹화해_응답을_만든다() { + // given + LocalDate targetDate = LocalDate.of(2026, 4, 15); + Exercise dayOneMorning = createMapExercise(901L, LocalDate.of(2026, 4, 3), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(9, 0)); + Exercise dayOneEveningSameBuilding = createMapExercise(902L, LocalDate.of(2026, 4, 3), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(19, 0)); + Exercise dayOneOtherBuilding = createMapExercise(903L, LocalDate.of(2026, 4, 3), + "B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042, LocalTime.of(13, 0)); + Exercise dayTwoBuilding = createMapExercise(904L, LocalDate.of(2026, 4, 4), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(10, 0)); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius(any(), any(), any(), any(), any())) + .willReturn(List.of(dayOneMorning, dayOneEveningSameBuilding, dayOneOtherBuilding, dayTwoBuilding)); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( + targetDate, null, null, radiusKm, mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(4); + assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); + assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings().keySet()) + .containsExactly(LocalDate.of(2026, 4, 3), LocalDate.of(2026, 4, 4)); + assertThat(response.buildings().get(LocalDate.of(2026, 4, 3))) + .extracting( + ExerciseMapBuildingsDTO.BuildingInfo::buildingName, + ExerciseMapBuildingsDTO.BuildingInfo::streetAddr, + ExerciseMapBuildingsDTO.BuildingInfo::latitude, + ExerciseMapBuildingsDTO.BuildingInfo::longitude + ) + .containsExactlyInAnyOrder( + tuple("A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041), + tuple("B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042) + ); + assertThat(response.buildings().get(LocalDate.of(2026, 4, 4))) + .extracting( + ExerciseMapBuildingsDTO.BuildingInfo::buildingName, + ExerciseMapBuildingsDTO.BuildingInfo::streetAddr + ) + .containsExactly(tuple("A빌딩", "서울특별시 강남구 테헤란로 10")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), null, null, radiusKm, 999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가 없으면 예외를 던진다") + void 대표주소가_없으면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), null, null, radiusKm, memberWithoutMainAddr.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); + } + + @Test + @DisplayName("대표주소가 없으면 명시 좌표가 있어도 예외를 던진다") + void 대표주소가_없으면_명시_좌표가_있어도_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), 37.5, 127.0, radiusKm, memberWithoutMainAddr.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); + } + + @Test + @DisplayName("위도와 경도 중 하나만 주면 예외를 던진다") + void 위도와_경도_중_하나만_주면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), 37.5, null, radiusKm, mapMember.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_LOCATION_INFO); + } + } + + private Exercise createMapExercise(long id, LocalDate date, String buildingName, + String streetAddr, double latitude, double longitude, + LocalTime startTime) { + Exercise mapExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(mapExercise, "id", id); + ReflectionTestUtils.setField(mapExercise, "startTime", startTime); + ReflectionTestUtils.setField(mapExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude)); + return mapExercise; + } + } + + @Nested + @DisplayName("getRecommendedExerciseCalendar") + class GetRecommendedExerciseCalendar { + + private Member recommendationMember; + private Member memberWithoutMainAddr; + private MemberAddr mainAddr; + private Party filteredParty; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + recommendationMember = MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 11001L, + LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(recommendationMember, "id", 11L); + mainAddr = MemberAddrFixture.createMainAddr(recommendationMember); + ReflectionTestUtils.setField(recommendationMember, "addresses", List.of(mainAddr)); + + memberWithoutMainAddr = MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 11002L, + LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 12L); + ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(MemberAddrFixture.createSubAddr(memberWithoutMainAddr))); + + party.addLevel(Gender.MALE, Level.A); + + filteredParty = PartyFixture.createParty("필터 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(filteredParty, "id", 20L); + ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE); + ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON); + filteredParty.addLevel(Gender.MALE, Level.B); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 4, 5); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("콕플 추천 기본 기간은 기본 범위를 사용하고 거리순으로 정렬한다") + void 콕플_추천_기본_기간은_기본_범위를_사용하고_거리순으로_정렬한다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + LocalDate targetDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, targetDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(targetDate); + + Exercise nearExercise = createRecommendationExercise(party, 1001L, targetDate, + LocalTime.of(11, 0), LocalTime.of(13, 0), 37.5, 127.0, "가까운 체육관"); + Exercise farExercise = createRecommendationExercise(party, 1002L, targetDate, + LocalTime.of(9, 0), LocalTime.of(11, 0), 35.1, 129.1, "먼 체육관"); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) + .willReturn(List.of(farExercise, nearExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + recommendationMember.getId(), List.of(farExercise.getId(), nearExercise.getId()))) + .willReturn(List.of(nearExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(farExercise.getId(), nearExercise.getId()))) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), null, null, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).hasSize(5); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises()) + .extracting( + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::startTime, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::endTime, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked + ) + .containsExactly( + tuple(nearExercise.getId(), party.getId(), "테스트 모임", "가까운 체육관", + LocalTime.of(11, 0), LocalTime.of(13, 0), true), + tuple(farExercise.getId(), party.getId(), "테스트 모임", "먼 체육관", + LocalTime.of(9, 0), LocalTime.of(11, 0), false) + ); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(0).distance()).isZero(); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(1).distance()).isGreaterThan(0.0); + verify(exerciseRepository).findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd); + verify(exerciseRepository, never()).findFilteredRecommendedExercisesForCalendar(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("필터 추천은 필터 리포지토리만 호출하고 인기순 정렬을 적용한다") + void 필터_추천은_필터_리포지토리만_호출하고_인기순_정렬을_적용한다() { + // given + Exercise popularExercise = createRecommendationExercise(filteredParty, 1101L, LocalDate.of(2026, 3, 25), + LocalTime.of(18, 0), LocalTime.of(20, 0), 37.52, 127.02, "인기 체육관"); + Exercise earlyExercise = createRecommendationExercise(filteredParty, 1102L, LocalDate.of(2026, 3, 25), + LocalTime.of(9, 0), LocalTime.of(11, 0), 37.53, 127.03, "이른 체육관"); + + ExerciseRecommendationCalendarDTO.FilterSortType filterSortType = ExerciseRecommendationCalendarDTO.FilterSortType.builder() + .addr1("서울특별시") + .addr2("강남구") + .levels(List.of(Level.B)) + .participationTypes(List.of(ParticipationType.SINGLE)) + .activityTimes(List.of(ActivityTime.AFTERNOON)) + .sortType(MyPartyExerciseOrderType.POPULARITY) + .build(); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findFilteredRecommendedExercisesForCalendar( + recommendationMember.getId(), 1995, filterSortType, startDate, endDate)) + .willReturn(List.of(earlyExercise, popularExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + recommendationMember.getId(), List.of(earlyExercise.getId(), popularExercise.getId()))) + .willReturn(List.of(popularExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(earlyExercise.getId(), popularExercise.getId()))) + .willReturn(List.of( + new Object[]{popularExercise.getId(), 3}, + new Object[]{earlyExercise.getId(), 1} + )); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), startDate, endDate, false, filterSortType); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::distance + ) + .containsExactly( + tuple(popularExercise.getId(), filteredParty.getId(), "필터 모임", "인기 체육관", true, null), + tuple(earlyExercise.getId(), filteredParty.getId(), "필터 모임", "이른 체육관", false, null) + ); + verify(exerciseRepository, never()).findCockpleRecommendedExercisesByDateRange(any(), any(), any(), anyInt(), any(), any()); + verify(exerciseRepository).findFilteredRecommendedExercisesForCalendar( + recommendationMember.getId(), 1995, filterSortType, startDate, endDate); + } + + @Test + @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다") + void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(2); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(0).exercises()).isEmpty(); + } + + @Test + @DisplayName("startDate만 주어져도 기본 기간이 적용된다") + void startDate만_주어져도_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), LocalDate.of(2026, 3, 25), null, true, + recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).hasSize(5); + } + + @Test + @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다") + void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() { + // given + LocalDate reversedStart = LocalDate.of(2026, 4, 5); + LocalDate reversedEnd = LocalDate.of(2026, 3, 23); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, reversedStart, reversedEnd)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), reversedStart, reversedEnd, true, + recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(reversedStart); + assertThat(response.endDate()).isEqualTo(reversedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExerciseCalendar( + 999L, startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가 없으면 예외를 던진다") + void 대표주소가_없으면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + memberWithoutMainAddr.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) + .willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExerciseCalendar( + memberWithoutMainAddr.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); + } + } + + private ExerciseRecommendationCalendarDTO.FilterSortType recommendationFilter(MyPartyExerciseOrderType sortType) { + return ExerciseRecommendationCalendarDTO.FilterSortType.builder() + .sortType(sortType) + .build(); + } + + private Exercise createRecommendationExercise(Party exerciseParty, long id, LocalDate date, + LocalTime startTime, LocalTime endTime, + double latitude, double longitude, String buildingName) { + Exercise recommendationExercise = ExerciseFixture.createRecommendableExercise( + exerciseParty, date, latitude, longitude, buildingName); + ReflectionTestUtils.setField(recommendationExercise, "id", id); + ReflectionTestUtils.setField(recommendationExercise, "startTime", startTime); + ReflectionTestUtils.setField(recommendationExercise, "endTime", endTime); + return recommendationExercise; + } + } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java new file mode 100644 index 000000000..4420a0d00 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java @@ -0,0 +1,304 @@ +package umc.cockple.demo.domain.exercise.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationDTO; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseQueryService - 사용자 추천 운동 조회") +class ExerciseRecommendationServiceTest { + + @InjectMocks + private ExerciseQueryService exerciseQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private GuestRepository guestRepository; + @Mock private PartyRepository partyRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private Member member; + private MemberAddr mainAddr; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + ReflectionTestUtils.setField(exerciseQueryService, "exerciseConverter", exerciseConverter); + + member = MemberFixture.createMember("테스트회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(member, "id", 1L); + + mainAddr = MemberAddrFixture.createMainAddr(member); + List addresses = new ArrayList<>(); + addresses.add(mainAddr); + ReflectionTestUtils.setField(member, "addresses", addresses); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + ExerciseAddr exerciseAddr = ExerciseFixture.createExerciseAddr(); + exercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(3), + null, true, true); + ReflectionTestUtils.setField(exercise, "id", 100L); + ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr); + } + + @Nested + @DisplayName("getRecommendedExercises") + class GetRecommendedExercises { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("추천 운동이 존재하면 운동 목록과 총 개수를 반환한다") + void 추천_운동이_존재하면_목록과_총개수를_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(1); + assertThat(response.exercises()).hasSize(1); + } + + @Test + @DisplayName("추천 운동의 필드가 올바르게 매핑된다") + void 추천_운동_필드가_올바르게_매핑된다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + ExerciseRecommendationDTO.ExerciseItem item = response.exercises().get(0); + assertThat(item.exerciseId()).isEqualTo(100L); + assertThat(item.partyId()).isEqualTo(10L); + assertThat(item.partyName()).isEqualTo("테스트 모임"); + assertThat(item.date()).isEqualTo(exercise.getDate()); + assertThat(item.dayOfWeek()).isEqualTo(exercise.getDate().getDayOfWeek().name()); + assertThat(item.buildingName()).isEqualTo("테스트 체육관"); + assertThat(item.isBookmarked()).isFalse(); + } + + @Test + @DisplayName("찜한 운동은 isBookmarked가 true로 반환된다") + void 찜한_운동은_isBookmarked가_true로_반환된다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of(100L)); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.exercises().get(0).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다") + void 추천_운동이_없으면_빈_목록을_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(Collections.emptyList()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(0); + assertThat(response.exercises()).isEmpty(); + } + + @Test + @DisplayName("추천 운동이 10개를 초과하면 거리순으로 최대 10개만 반환된다") + void 추천_운동이_10개_초과하면_거리순으로_10개만_반환된다() { + // given - 같은 위치(거리 0)의 운동 12개 생성 + List candidates = new ArrayList<>(); + for (int i = 1; i <= 12; i++) { + Exercise ex = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(i), + null, true, true); + ReflectionTestUtils.setField(ex, "id", (long) (100 + i)); + ReflectionTestUtils.setField(ex, "exerciseAddr", ExerciseFixture.createExerciseAddr()); + candidates.add(ex); + } + + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(candidates); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(10); + assertThat(response.exercises()).hasSize(10); + } + + @Test + @DisplayName("거리가 가까운 운동이 먼저 정렬된다") + void 거리가_가까운_운동이_먼저_정렬된다() { + // given - 거리가 다른 두 운동 (좌표 차이로 구분) + ExerciseAddr nearAddr = ExerciseAddr.builder() + .addr1("서울특별시").addr2("강남구") + .streetAddr("테헤란로 1").buildingName("가까운 체육관") + .latitude(37.5).longitude(127.0) // mainAddr과 동일 위치 -> 거리 0 + .build(); + ExerciseAddr farAddr = ExerciseAddr.builder() + .addr1("부산광역시").addr2("해운대구") + .streetAddr("해운대로 1").buildingName("먼 체육관") + .latitude(35.1).longitude(129.1) // 부산 -> 거리 멀다 + .build(); + + Exercise nearExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(5), + null, true, true); + ReflectionTestUtils.setField(nearExercise, "id", 101L); + ReflectionTestUtils.setField(nearExercise, "exerciseAddr", nearAddr); + + Exercise farExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(1), + null, true, true); + ReflectionTestUtils.setField(farExercise, "id", 102L); + ReflectionTestUtils.setField(farExercise, "exerciseAddr", farAddr); + + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(farExercise, nearExercise)); // 먼 것을 먼저 넣어도 + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then - 가까운 운동이 먼저 + assertThat(response.exercises().get(0).exerciseId()).isEqualTo(101L); + assertThat(response.exercises().get(1).exerciseId()).isEqualTo(102L); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MEMBER_NOT_FOUND 예외가 발생한다") + void 존재하지_않는_회원이면_예외가_발생한다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExercises(999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("대표 주소가 없으면 MAIN_ADDRESS_NULL 예외가 발생한다") + void 대표_주소가_없으면_예외가_발생한다() { + // given - addresses 비어 있는 member + Member memberWithoutAddr = MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 2001L, LocalDate.of(1995, 1, 1)); + ReflectionTestUtils.setField(memberWithoutAddr, "id", 2L); + ReflectionTestUtils.setField(memberWithoutAddr, "addresses", new ArrayList<>()); + + given(memberRepository.findMemberWithAddresses(2L)) + .willReturn(Optional.of(memberWithoutAddr)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExercises(2L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MAIN_ADDRESS_NULL)); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java b/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java new file mode 100644 index 000000000..9a4dccaa2 --- /dev/null +++ b/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java @@ -0,0 +1,30 @@ +package umc.cockple.demo.support; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +public final class ExerciseCalendarTestHelper { + + private ExerciseCalendarTestHelper() { + } + + public static LocalDate expectedDefaultStartDate() { + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1L); + return thisWeekMonday.minusWeeks(1); + } + + public static LocalDate expectedDefaultEndDate() { + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1L); + return thisWeekMonday.plusWeeks(3).plusDays(6); + } + + public static int weekIndexFor(LocalDate expectedStart, LocalDate targetDate) { + return (int) (ChronoUnit.DAYS.between(expectedStart, targetDate) / 7); + } + + public static int dayIndexFor(LocalDate targetDate) { + return targetDate.getDayOfWeek().getValue() - 1; + } +} diff --git a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java index 5c65162b4..097910997 100644 --- a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java +++ b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java @@ -14,7 +14,8 @@ public class IntegrationTestConfig { private static final MySQLContainer mysql = - new MySQLContainer<>("mysql:8.0.36"); + new MySQLContainer<>("mysql:8.0.36") + .withConfigurationOverride("mysql-conf"); private static final RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7.2-alpine")); diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java index aca0f3fde..1780fcc17 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java @@ -9,52 +9,71 @@ public class ExerciseFixture { - public static Exercise createExercise(Party party, LocalDate date) { - return Exercise.builder() - .party(party) - .date(date) - .startTime(LocalTime.of(10, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) + public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr, + double latitude, double longitude) { + return ExerciseAddr.builder() + .addr1("서울특별시") + .addr2("강남구") + .streetAddr(streetAddr) + .buildingName(buildingName) + .latitude(latitude) + .longitude(longitude) .build(); } - public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, - boolean partyGuestAccept, boolean outsideGuestAccept) { + public static ExerciseAddr createExerciseAddr() { + return createExerciseAddr("테스트 체육관", "서울특별시 강남구 테헤란로 1"); + } + + public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr) { + return createExerciseAddr(buildingName, streetAddr, 37.5, 127.0); + } + + private static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, + int maxCapacity, boolean partyGuestAccept, + boolean outsideGuestAccept, ExerciseAddr exerciseAddr, + String notice) { return Exercise.builder() .party(party) .date(date) .startTime(LocalTime.of(10, 0)) .endTime(endTime) - .maxCapacity(10) + .maxCapacity(maxCapacity) .partyGuestAccept(partyGuestAccept) .outsideGuestAccept(outsideGuestAccept) + .exerciseAddr(exerciseAddr) + .notice(notice) .build(); } + public static Exercise createExercise(Party party, LocalDate date) { + return createExercise(party, date, null, true, false); + } + + public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, + boolean partyGuestAccept, boolean outsideGuestAccept) { + return createExercise(party, date, endTime, 10, partyGuestAccept, outsideGuestAccept, + null, null); + } + public static Exercise createExerciseWithAddr(Party party, LocalDate date) { return createExerciseWithAddr(party, date, 10); } public static Exercise createExerciseWithAddr(Party party, LocalDate date, int maxCapacity) { - ExerciseAddr addr = ExerciseAddr.builder() - .addr1("서울특별시") - .addr2("강남구") - .streetAddr("서울특별시 강남구 테헤란로 1") - .buildingName("테스트 체육관") - .latitude(37.5) - .longitude(127.0) - .build(); + return createExercise(party, date, null, maxCapacity, true, false, + createExerciseAddr(), null); + } - return Exercise.builder() - .party(party) - .date(date) - .startTime(LocalTime.of(10, 0)) - .maxCapacity(maxCapacity) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .exerciseAddr(addr) - .build(); + public static Exercise createRecommendableExercise(Party party, LocalDate date, + double latitude, double longitude, + String buildingName) { + return createExercise(party, date, LocalTime.of(12, 0), 10, true, true, + createExerciseAddr(buildingName, "테헤란로 1", latitude, longitude), null); + } + + public static Exercise createExerciseForEdit(Party party, LocalDate date) { + return createExercise(party, date, LocalTime.of(12, 30), 18, true, false, + createExerciseAddr(), "수정 공지사항"); } } diff --git a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java index 1fd4044d2..23ccffb05 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java @@ -8,13 +8,17 @@ public class GuestFixture { public static Guest createGuest(Exercise exercise, Long inviterId) { + return createGuest(exercise, inviterId, "게스트", Gender.MALE); + } + + public static Guest createGuest(Exercise exercise, Long inviterId, String guestName, Gender gender) { Guest guest = Guest.builder() - .guestName("게스트") - .gender(Gender.MALE) + .guestName(guestName) + .gender(gender) .level(Level.B) .inviterId(inviterId) .build(); guest.setExercise(exercise); return guest; } -} \ No newline at end of file +} diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java index 1ed7b6cf3..96d1f147a 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java @@ -79,4 +79,12 @@ public static MemberExercise createMemberExercise(Member member, Exercise exerci .exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER) .build(); } + + public static MemberExercise createExternalMemberExercise(Member member, Exercise exercise) { + return MemberExercise.builder() + .member(member) + .exercise(exercise) + .exerciseMemberShipStatus(ExerciseMemberShipStatus.EXTERNAL_PARTICIPANT) + .build(); + } } diff --git a/src/test/resources/mysql-conf/timezone.cnf b/src/test/resources/mysql-conf/timezone.cnf new file mode 100644 index 000000000..577235cfe --- /dev/null +++ b/src/test/resources/mysql-conf/timezone.cnf @@ -0,0 +1,2 @@ +[mysqld] +default-time-zone = '+09:00' From d3e14f76685c0d47b8988f84dad81d3bcb50cdd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=95=98=EB=82=98?= <107329874+kanghana1@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:46:36 +0900 Subject: [PATCH 19/20] =?UTF-8?q?[test/#517]=20Notification=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 통합테스트 구현 * test: 단위테스트 코드 작성 * test: fcm 통합테스트코드 작성 * fix: 통합테스트 피드백 반영 * chore: 중복테스트 제거 --- .../repository/NotificationRepository.java | 3 +- .../service/NotificationCommandService.java | 2 +- .../service/NotificationQueryService.java | 2 +- .../notification/fcm/FcmIntegrationTest.java | 134 +++++++ .../NotificationIntegrationTest.java | 297 ++++++++++++++ .../NotificationCommandServiceTest.java | 375 ++++++++++++++++++ .../service/NotificationQueryServiceTest.java | 273 +++++++++++++ 7 files changed, 1082 insertions(+), 4 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java index c371e57c0..eb870bec6 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java @@ -10,8 +10,7 @@ public interface NotificationRepository extends JpaRepository { - List findAllByMember(Member member); + List findAllByMemberOrderByCreatedAtDesc(Member member); Optional findFirstByMemberAndTypeNotOrderByCreatedAtAsc(Member member, NotificationType type); - } diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java index 6f4c3c594..f62a4d6f5 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java @@ -63,7 +63,7 @@ public void createNotification(CreateNotificationRequestDTO dto) { try { Member member = dto.member(); - List bookmarks = notificationRepository.findAllByMember(member); + List bookmarks = notificationRepository.findAllByMemberOrderByCreatedAtDesc(member); if (bookmarks.size() >= 50) { // INVITE타입이 아니면서 가장 오래된 거 삭제 notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(member, NotificationType.INVITE) diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java index 22eaa5afd..baa08008d 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java @@ -33,7 +33,7 @@ public List getAllNotifications(Long memberId) { Member member = findByMemberId(memberId); // 회원의 모든 알림 조회 - List notifications = notificationRepository.findAllByMember(member); + List notifications = notificationRepository.findAllByMemberOrderByCreatedAtDesc(member); if (notifications.isEmpty()) { return List.of(); diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java new file mode 100644 index 000000000..2b73693d3 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java @@ -0,0 +1,134 @@ +package umc.cockple.demo.domain.notification.fcm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.firebase.messaging.FirebaseMessaging; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.dto.FcmTokenRequestDTO; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class FcmIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired ObjectMapper objectMapper; + + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save( + MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L)); + } + + @AfterEach + void tearDown() { + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("PATCH /api/notifications/fcm-token - FCM 토큰 등록/갱신") + class RegisterFcmToken { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - FCM 토큰이 DB에 저장된다") + void registerFcmToken_savedInDb() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("new-fcm-token")))) + .andExpect(status().isOk()); + + Member updated = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(updated.getFcmToken()).isEqualTo("new-fcm-token"); + } + + @Test + @DisplayName("200 - 기존 토큰이 새 토큰으로 교체된다") + void registerFcmToken_updatesExistingToken() throws Exception { + ReflectionTestUtils.setField(member, "fcmToken", "old-fcm-token"); + memberRepository.save(member); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("updated-fcm-token")))) + .andExpect(status().isOk()); + + Member updated = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(updated.getFcmToken()).isEqualTo("updated-fcm-token"); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("400 - 빈 문자열 토큰은 @NotBlank 검증에서 거부된다") + void registerFcmToken_blankToken_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("")))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("400 - null 토큰은 @NotBlank 검증에서 거부된다") + void registerFcmToken_nullToken_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(null)))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("400 - 공백 문자열 토큰은 @NotBlank 검증에서 거부된다") + void registerFcmToken_whitespaceToken_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(" ")))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("400 - fcmToken 필드 누락 시 @NotBlank 검증에서 거부된다") + void registerFcmToken_missingField_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + } + } + +} diff --git a/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java new file mode 100644 index 000000000..42ebb5554 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java @@ -0,0 +1,297 @@ +package umc.cockple.demo.domain.notification.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class NotificationIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired NotificationRepository notificationRepository; + + @MockitoBean + FileService fileService; + + private Member member; + private Notification notification; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L)); + + notification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(100L) + .title("테스트 모임") + .content("테스트 알림 내용") + .type(NotificationType.INVITE) + .isRead(false) + .imageKey("test-image-key") + .data("{\"invitationId\":1}") + .build()); + + given(fileService.getUrlFromKey("test-image-key")) + .willReturn("https://test-storage.com/test-image-key"); + given(fileService.getUrlFromKey(null)) + .willReturn(null); + } + + @AfterEach + void tearDown() { + notificationRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("GET /api/notifications - 내 알림 전체 조회") + class GetAllNotifications { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 알림 목록의 모든 필드를 반환한다") + void getAllNotifications_allFields() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].notificationId").value(notification.getId())) + .andExpect(jsonPath("$.data[0].partyId").value(100)) + .andExpect(jsonPath("$.data[0].title").value("테스트 모임")) + .andExpect(jsonPath("$.data[0].content").value("테스트 알림 내용")) + .andExpect(jsonPath("$.data[0].type").value("INVITE")) + .andExpect(jsonPath("$.data[0].isRead").value(false)) + .andExpect(jsonPath("$.data[0].imgUrl").value("https://test-storage.com/test-image-key")) + .andExpect(jsonPath("$.data[0].data").value("{\"invitationId\":1}")); + } + + @Test + @DisplayName("200 - 알림이 없으면 빈 리스트를 반환한다") + void getAllNotifications_empty() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + + @Test + @DisplayName("200 - 알림 목록이 createdAt 기준 내림차순으로 정렬된다") + void getAllNotifications_sortedByCreatedAtDesc() throws Exception { + // 먼저 저장된 알림 (더 오래된) + Notification olderNotification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(200L) + .title("오래된 알림") + .content("먼저 생성된 알림") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + + Thread.sleep(10); // createdAt이 서로 다르도록 대기 + + // 나중에 저장된 알림 (더 최신) + Notification newerNotification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(300L) + .title("최신 알림") + .content("나중에 생성된 알림") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // 내림차순이면 newerNotification → olderNotification → setUp의 notification 순 + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(3))) + .andExpect(jsonPath("$.data[0].notificationId").value(newerNotification.getId())) + .andExpect(jsonPath("$.data[1].notificationId").value(olderNotification.getId())) + .andExpect(jsonPath("$.data[2].notificationId").value(notification.getId())); + } + + @Test + @DisplayName("200 - imageKey가 없는 알림은 imgUrl이 null로 반환된다") + void getAllNotifications_nullImgUrl() throws Exception { + notificationRepository.save(Notification.builder() + .member(member) + .partyId(200L) + .title("이미지 없는 알림") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].imgUrl").value(nullValue())); + } + } + } + + + @Nested + @DisplayName("GET /api/notifications/count - 안 읽은 알림 존재여부 조회") + class CheckUnreadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 읽지 않은 알림이 있으면 existNewNotification이 true이다") + void checkUnread_hasUnread() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.existNewNotification").value(true)); + } + + @Test + @DisplayName("200 - 모든 알림을 읽으면 existNewNotification이 false이다") + void checkUnread_allRead() throws Exception { + notification.read(); + notificationRepository.save(notification); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.existNewNotification").value(false)); + } + + @Test + @DisplayName("200 - 알림이 전혀 없으면 existNewNotification이 false이다") + void checkUnread_noNotification() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/notifications/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.existNewNotification").value(false)); + } + } + } + + + @Nested + @DisplayName("PATCH /api/notifications/{notificationId} - 특정 알림 읽음 처리") + class MarkAsReadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - INVITE 알림을 INVITE_ACCEPT로 읽음 처리하면 변경된 타입을 반환한다") + void markAsRead_inviteAccept() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId()) + .param("type", NotificationType.INVITE_ACCEPT.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("INVITE_ACCEPT")); + } + + @Test + @DisplayName("200 - INVITE 알림을 INVITE_REJECT로 읽음 처리하면 변경된 타입을 반환한다") + void markAsRead_inviteReject() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId()) + .param("type", NotificationType.INVITE_REJECT.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("INVITE_REJECT")); + } + + @Test + @DisplayName("200 - SIMPLE 알림을 읽음 처리하면 SIMPLE 타입을 반환한다") + void markAsRead_simple() throws Exception { + Notification simpleNotification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(100L) + .title("테스트 모임") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", simpleNotification.getId()) + .param("type", NotificationType.SIMPLE.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("SIMPLE")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 알림 ID이면 에러를 반환한다") + void notificationNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", 999L) + .param("type", NotificationType.SIMPLE.name())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(NotificationErrorCode.NOTIFICATION_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(NotificationErrorCode.NOTIFICATION_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("401 - 다른 사용자의 알림에 접근하면 에러를 반환한다") + void notificationNotOwned() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId()) + .param("type", NotificationType.SIMPLE.name())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(NotificationErrorCode.NOTIFICATION_NOT_OWNED.getCode())) + .andExpect(jsonPath("$.message").value(NotificationErrorCode.NOTIFICATION_NOT_OWNED.getMessage())); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java new file mode 100644 index 000000000..d117e782b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java @@ -0,0 +1,375 @@ +package umc.cockple.demo.domain.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.dto.CreateNotificationRequestDTO; +import umc.cockple.demo.domain.notification.enums.NotificationTarget; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; +import umc.cockple.demo.domain.notification.exception.NotificationException; +import umc.cockple.demo.domain.notification.fcm.FcmService; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationCommandService") +class NotificationCommandServiceTest { + + @InjectMocks + private NotificationCommandService notificationCommandService; + + @Mock private NotificationRepository notificationRepository; + @Mock private MemberRepository memberRepository; + @Mock private PartyRepository partyRepository; + @Mock private NotificationMessageGenerator notificationMessageGenerator; + @Mock private ObjectMapper objectMapper; + @Mock private FcmService fcmService; + + private Member member; + private Party party; + private Notification notification; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + notification = Notification.builder() + .member(member) + .partyId(party.getId()) + .title("테스트 모임") + .content("테스트 알림 내용") + .type(NotificationType.INVITE) + .isRead(false) + .imageKey(null) + .data("{\"invitationId\":1}") + .build(); + ReflectionTestUtils.setField(notification, "id", 100L); + } + + + @Nested + @DisplayName("markAsReadNotification - 알림 읽음 처리") + class MarkAsReadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("INVITE 알림을 INVITE_ACCEPT로 읽음 처리하면 변경된 타입과 읽음 상태를 반환한다") + void markAsRead_inviteAccept_returnsChangedType() { + // given + given(notificationRepository.findById(notification.getId())) + .willReturn(Optional.of(notification)); + + // when + Response result = notificationCommandService.markAsReadNotification( + member.getId(), notification.getId(), NotificationType.INVITE_ACCEPT); + + // then + assertThat(result.type()).isEqualTo(NotificationType.INVITE_ACCEPT); + assertThat(notification.getIsRead()).isTrue(); + } + + @Test + @DisplayName("INVITE 알림을 INVITE_REJECT로 읽음 처리하면 변경된 타입과 읽음 상태를 반환한다") + void markAsRead_inviteReject_returnsChangedType() { + // given + given(notificationRepository.findById(notification.getId())) + .willReturn(Optional.of(notification)); + + // when + Response result = notificationCommandService.markAsReadNotification( + member.getId(), notification.getId(), NotificationType.INVITE_REJECT); + + // then + assertThat(result.type()).isEqualTo(NotificationType.INVITE_REJECT); + assertThat(notification.getIsRead()).isTrue(); + } + + @Test + @DisplayName("SIMPLE 알림을 읽음 처리하면 SIMPLE 타입과 읽음 상태를 반환한다") + void markAsRead_simple_returnsSameType() { + // given + Notification simpleNotification = Notification.builder() + .member(member) + .partyId(100L) + .title("단순 알림") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build(); + ReflectionTestUtils.setField(simpleNotification, "id", 200L); + given(notificationRepository.findById(200L)).willReturn(Optional.of(simpleNotification)); + + // when + Response result = notificationCommandService.markAsReadNotification( + member.getId(), 200L, NotificationType.SIMPLE); + + // then + assertThat(result.type()).isEqualTo(NotificationType.SIMPLE); + assertThat(simpleNotification.getIsRead()).isTrue(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 알림이면 NotificationException(NOTIFICATION_NOT_FOUND)을 던진다") + void notificationNotFound_throwsNotificationException() { + // given + given(notificationRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationCommandService.markAsReadNotification( + member.getId(), 999L, NotificationType.SIMPLE)) + .isInstanceOf(NotificationException.class) + .satisfies(e -> assertThat(((NotificationException) e).getCode()) + .isEqualTo(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + } + + @Test + @DisplayName("다른 사용자의 알림이면 NotificationException(NOTIFICATION_NOT_OWNED)을 던진다") + void notificationNotOwned_throwsNotificationException() { + // given + given(notificationRepository.findById(notification.getId())) + .willReturn(Optional.of(notification)); + + // when & then + assertThatThrownBy(() -> notificationCommandService.markAsReadNotification( + 999L, notification.getId(), NotificationType.SIMPLE)) + .isInstanceOf(NotificationException.class) + .satisfies(e -> assertThat(((NotificationException) e).getCode()) + .isEqualTo(NotificationErrorCode.NOTIFICATION_NOT_OWNED)); + } + } + } + + + @Nested + @DisplayName("createNotification - 알림 생성") + class CreateNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("PARTY_DELETE 알림을 생성하면 저장 및 FCM 전송이 호출된다") + void createNotification_partyDelete_savesAndSendsFcm() throws Exception { + // given + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generatePartyDeletedMessage()) + .willReturn("모임이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should().save(any(Notification.class)); + then(fcmService).should().sendNotification(eq(member), eq("테스트 모임"), eq("모임이 삭제되었어요!")); + } + + @Test + @DisplayName("PARTY_INVITE 알림을 생성하면 title이 '새로운 모임'으로 FCM이 전송된다") + void createNotification_partyInvite_usesTitleNewParty() throws Exception { + // given + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .invitationId(1L) + .target(NotificationTarget.PARTY_INVITE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generateInviteMessage(party.getPartyName())) + .willReturn("'테스트 모임' 모임에 초대를 받았습니다."); + given(objectMapper.writeValueAsString(any())).willReturn("{\"invitationId\":1}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(fcmService).should().sendNotification(eq(member), eq("새로운 모임"), any(String.class)); + } + + @Test + @DisplayName("EXERCISE_DELETE 알림을 생성하면 날짜 포맷을 포함한 메시지로 저장된다") + void createNotification_exerciseDelete_savesWithFormattedDate() throws Exception { + // given + LocalDate exerciseDate = LocalDate.of(2025, 3, 15); + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .exerciseDate(exerciseDate) + .target(NotificationTarget.EXERCISE_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generateExerciseDeletedMessage("03.15(토)")) + .willReturn("03.15(토) 운동이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should().save(any(Notification.class)); + then(fcmService).should().sendNotification(eq(member), eq("테스트 모임"), eq("03.15(토) 운동이 삭제되었어요!")); + } + + @Test + @DisplayName("알림이 50개 이상이면 INVITE가 아닌 가장 오래된 알림을 삭제 후 저장한다") + void createNotification_over50_deletesOldestNonInvite() throws Exception { + // given + List existingNotifications = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + existingNotifications.add(Notification.builder() + .member(member).partyId(100L).title("t").content("c") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build()); + } + + Notification oldestNonInvite = Notification.builder() + .member(member).partyId(100L).title("오래된 알림").content("c") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build(); + + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(existingNotifications); + given(notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc( + member, NotificationType.INVITE)) + .willReturn(Optional.of(oldestNonInvite)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generatePartyDeletedMessage()) + .willReturn("모임이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should().delete(oldestNonInvite); + then(notificationRepository).should().save(any(Notification.class)); + } + + @Test + @DisplayName("알림이 49개이면 오래된 알림 삭제 없이 바로 저장한다") + void createNotification_under50_savesWithoutDelete() throws Exception { + // given + List existingNotifications = new ArrayList<>(); + for (int i = 0; i < 49; i++) { + existingNotifications.add(Notification.builder() + .member(member).partyId(100L).title("t").content("c") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build()); + } + + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(existingNotifications); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generatePartyDeletedMessage()) + .willReturn("모임이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should(never()).delete(any()); + then(notificationRepository).should().save(any(Notification.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던지고 저장하지 않는다") + void partyNotFound_throwsPartyException() { + // given + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(999L) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationCommandService.createNotification(dto)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + + then(notificationRepository).should(never()).save(any()); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java new file mode 100644 index 000000000..8aef6bdc5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java @@ -0,0 +1,273 @@ +package umc.cockple.demo.domain.notification.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO; +import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationQueryService") +class NotificationQueryServiceTest { + + @InjectMocks + private NotificationQueryService notificationQueryService; + + @Mock private NotificationRepository notificationRepository; + @Mock private MemberRepository memberRepository; + @Mock private FileService fileService; + + private Member member; + private Notification notification; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + notification = Notification.builder() + .member(member) + .partyId(100L) + .title("테스트 모임") + .content("테스트 알림 내용") + .type(NotificationType.INVITE) + .isRead(false) + .imageKey("test-image-key") + .data("{\"invitationId\":1}") + .build(); + ReflectionTestUtils.setField(notification, "id", 10L); + } + + + @Nested + @DisplayName("getAllNotifications - 내 알림 전체 조회") + class GetAllNotifications { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("알림 목록을 DTO로 변환하여 반환한다") + void getAllNotifications_returnsDtoList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of(notification)); + given(fileService.getUrlFromKey("test-image-key")) + .willReturn("https://test-storage.com/test-image-key"); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).notificationId()).isEqualTo(10L); + assertThat(result.get(0).partyId()).isEqualTo(100L); + assertThat(result.get(0).title()).isEqualTo("테스트 모임"); + assertThat(result.get(0).content()).isEqualTo("테스트 알림 내용"); + assertThat(result.get(0).type()).isEqualTo(NotificationType.INVITE); + assertThat(result.get(0).isRead()).isFalse(); + assertThat(result.get(0).imgUrl()).isEqualTo("https://test-storage.com/test-image-key"); + assertThat(result.get(0).data()).isEqualTo("{\"invitationId\":1}"); + } + + @Test + @DisplayName("imageKey가 없는 알림은 imgUrl이 null로 반환된다") + void getAllNotifications_nullImageKey_returnsNullImgUrl() { + // given + Notification noImageNotification = Notification.builder() + .member(member) + .partyId(200L) + .title("이미지 없는 알림") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build(); + ReflectionTestUtils.setField(noImageNotification, "id", 20L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of(noImageNotification)); + given(fileService.getUrlFromKey(null)).willReturn(null); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).imgUrl()).isNull(); + } + + @Test + @DisplayName("레포지토리가 반환한 createdAt 내림차순 순서를 그대로 유지하여 반환한다") + void getAllNotifications_preservesDescOrderFromRepository() { + // given + Notification oldest = Notification.builder() + .member(member).partyId(100L).title("오래된 알림").content("c1") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build(); + ReflectionTestUtils.setField(oldest, "id", 1L); + ReflectionTestUtils.setField(oldest, "createdAt", LocalDateTime.of(2025, 1, 1, 9, 0)); + + Notification middle = Notification.builder() + .member(member).partyId(100L).title("중간 알림").content("c2") + .type(NotificationType.CHANGE).isRead(false).imageKey(null).data("{}").build(); + ReflectionTestUtils.setField(middle, "id", 2L); + ReflectionTestUtils.setField(middle, "createdAt", LocalDateTime.of(2025, 6, 1, 9, 0)); + + Notification newest = Notification.builder() + .member(member).partyId(100L).title("최신 알림").content("c3") + .type(NotificationType.INVITE).isRead(false).imageKey(null).data("{}").build(); + ReflectionTestUtils.setField(newest, "id", 3L); + ReflectionTestUtils.setField(newest, "createdAt", LocalDateTime.of(2025, 12, 1, 9, 0)); + + // 레포지토리는 createdAt DESC 순으로 반환 (newest → middle → oldest) + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of(newest, middle, oldest)); + given(fileService.getUrlFromKey(null)).willReturn(null); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(0).notificationId()).isEqualTo(3L); // newest + assertThat(result.get(1).notificationId()).isEqualTo(2L); // middle + assertThat(result.get(2).notificationId()).isEqualTo(1L); // oldest + } + + @Test + @DisplayName("알림이 없으면 빈 리스트를 반환한다") + void getAllNotifications_noNotifications_returnsEmptyList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationQueryService.getAllNotifications(999L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + + @Nested + @DisplayName("checkUnreadNotification - 읽지 않은 알림 존재여부 조회") + class CheckUnreadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("읽지 않은 알림이 있으면 existNewNotification이 true이다") + void hasUnreadNotification_returnsTrue() { + // given + ReflectionTestUtils.setField(member, "notifications", List.of(notification)); + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); + + // then + assertThat(result.existNewNotification()).isTrue(); + } + + @Test + @DisplayName("모든 알림이 읽힌 상태이면 existNewNotification이 false이다") + void allNotificationsRead_returnsFalse() { + // given + notification.read(); + ReflectionTestUtils.setField(member, "notifications", List.of(notification)); + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); + + // then + assertThat(result.existNewNotification()).isFalse(); + } + + @Test + @DisplayName("알림이 없으면 existNewNotification이 false이다") + void noNotifications_returnsFalse() { + // given + ReflectionTestUtils.setField(member, "notifications", List.of()); + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); + + // then + assertThat(result.existNewNotification()).isFalse(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationQueryService.checkUnreadNotification(999L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } +} From e331d3d91e878f4dcfabbe5300f7b6464ce6e67b Mon Sep 17 00:00:00 2001 From: Yumin Kwon Date: Thu, 2 Apr 2026 10:52:38 +0900 Subject: [PATCH 20/20] =?UTF-8?q?[test/#519]=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#558)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * debug: 모임 추천 조회 (콕플 추천 모드) 검색 기능 누락 해결 * test: 내 모임 조회(/api/my/parties) API 테스트 코드 작성 * test: 내 모임 간략화 조회(/api/my/parties/simple) API 테스트 코드 작성 * test: 모임 추천 조회(/api/my/parties/suggestions) API 테스트 코드 작성 * test: 모임 상세조회(/api/parties/{partyId}) API 테스트 코드 작성 * test: 모임 멤버 조회(/api/parties/{partyId}/members) API 테스트 코드 작성 * test: 모임 탈퇴(/api/parties/{partyId}/members/my) API 테스트 코드 작성 * test: @DisplayName 컨벤션 맞추기 * test: 모임 가입 신청 (/api/parties/{partyId}/join-requests) API 테스트 코드 작성 * test: 모임 생성 (/api/parties) API 테스트 코드 작성 * test: 모임 통합/단위테스트 컨벤션 맞추기 및 정리 * test: 모임 정보 수정 (/api/parties/{partyId}) API 테스트 코드 작성 * test: 멤버 역할(부모임장) 설정 (/api/parties/{partyId}/members/{memberId}/role) API 테스트 코드 작성 * test: 메서드명 수정 * test: 모임 삭제 (/api/parties/{partyId}/status) API 테스트 코드 작성 * test: 모임 멤버 삭제(/api/parties/{partyId}/members/{memberId}) API 테스트 코드 작성 * test: 가입신청 멤버 조회 (/api/parties/{partyId}/join-requests) API 테스트 코드 작성 * test: 가입 신청 처리 (/api/parties/{partyId}/join-requests/{requestId}) API 테스트 코드 작성 * test: 가입 승인 멤버 조회 (/api/parties/{partyId}/join-requests) API 테스트 코드 작성 * test: 신규 멤버 추천받기 (/api/parties/{partyId}/members/suggestions) API 테스트 코드 작성 * test: 신규 멤버 초대 보내기(/api/parties/{partyId}/invitations) API 테스트 코드 작성 * test: 모임 초대 처리(/api/parties/invitations/{invitationId}) API 테스트 코드 작성 * test: 키워드 추가(/api/parties/{partyId}/keywords) API 테스트 코드 작성 * chore: 모임 역할 enum 대문자로 통일 * chore: import문 정리 및 줄맞춤 * TEST: 내 모임 조회 API 정렬 기준에 따른 테스트 케이스 추가 * TEST: 멤버 역할 설정 API 실패, 성공 테스트 케이스 추가 * test: 모임 가입 신청 처리 API 실패 케이스 추가 및 enum 수정 * test: 모임 상세조회 API 필드 추가 * test: 모임 멤버 삭제 API 성공, 실패 테스트 케이스 추가 * test: MEMBER_NOT_FOUND, PARTY_NOT_FOUND 실패 테스트 케이스 추가 * test: 내 모임 조회(/api/my/parties) API 테스트 코드 작성 * test: 내 모임 간략화 조회(/api/my/parties/simple) API 테스트 코드 작성 * test: 모임 추천 조회(/api/my/parties/suggestions) API 테스트 코드 작성 * test: 모임 상세조회(/api/parties/{partyId}) API 테스트 코드 작성 * test: 모임 멤버 조회(/api/parties/{partyId}/members) API 테스트 코드 작성 * test: 모임 탈퇴(/api/parties/{partyId}/members/my) API 테스트 코드 작성 * test: @DisplayName 컨벤션 맞추기 * test: 모임 가입 신청 (/api/parties/{partyId}/join-requests) API 테스트 코드 작성 * test: 모임 생성 (/api/parties) API 테스트 코드 작성 * test: 모임 통합/단위테스트 컨벤션 맞추기 및 정리 * test: 모임 정보 수정 (/api/parties/{partyId}) API 테스트 코드 작성 * test: 멤버 역할(부모임장) 설정 (/api/parties/{partyId}/members/{memberId}/role) API 테스트 코드 작성 * test: 메서드명 수정 * test: 모임 삭제 (/api/parties/{partyId}/status) API 테스트 코드 작성 * test: 모임 멤버 삭제(/api/parties/{partyId}/members/{memberId}) API 테스트 코드 작성 * test: 가입신청 멤버 조회 (/api/parties/{partyId}/join-requests) API 테스트 코드 작성 * test: 가입 신청 처리 (/api/parties/{partyId}/join-requests/{requestId}) API 테스트 코드 작성 * test: 가입 승인 멤버 조회 (/api/parties/{partyId}/join-requests) API 테스트 코드 작성 * test: 신규 멤버 추천받기 (/api/parties/{partyId}/members/suggestions) API 테스트 코드 작성 * test: 신규 멤버 초대 보내기(/api/parties/{partyId}/invitations) API 테스트 코드 작성 * test: 모임 초대 처리(/api/parties/invitations/{invitationId}) API 테스트 코드 작성 * test: 키워드 추가(/api/parties/{partyId}/keywords) API 테스트 코드 작성 * chore: 모임 역할 enum 대문자로 통일 * chore: import문 정리 및 줄맞춤 * TEST: 내 모임 조회 API 정렬 기준에 따른 테스트 케이스 추가 * TEST: 멤버 역할 설정 API 실패, 성공 테스트 케이스 추가 * test: 모임 가입 신청 처리 API 실패 케이스 추가 및 enum 수정 * test: 모임 상세조회 API 필드 추가 * test: 모임 멤버 삭제 API 성공, 실패 테스트 케이스 추가 * test: MEMBER_NOT_FOUND, PARTY_NOT_FOUND 실패 테스트 케이스 추가 * test: 내 모임 조회 API 정렬 기준 검증 수정, 추천 모임 조회 API 정렬 기준 및 검색 기능 검증 추가 * test: import 수행 --- .../service/ExerciseQueryService.java | 2 +- .../exercise/service/ExerciseValidator.java | 4 +- .../domain/member/domain/MemberParty.java | 8 +- .../party/converter/PartyConverter.java | 6 +- .../party/exception/PartyErrorCode.java | 4 +- .../service/PartyCommandServiceImpl.java | 20 +- .../umc/cockple/demo/global/enums/Role.java | 6 +- .../integration/BookmarkIntegrationTest.java | 2 +- .../chat/integration/ChatIntegrationTest.java | 4 +- .../ExerciseCommandIntegrationTest.java | 8 +- .../ExerciseQueryIntegrationTest.java | 20 +- ...ExerciseRecommendationIntegrationTest.java | 4 +- .../service/ExerciseLifecycleServiceTest.java | 24 +- .../ExerciseParticipationServiceTest.java | 8 +- .../service/ExerciseQueryServiceTest.java | 56 +- .../integration/MemberIntegrationTest.java | 12 +- .../service/MemberCommandServiceTest.java | 6 +- .../service/MemberQueryServiceTest.java | 4 +- .../integration/PartyIntegrationTest.java | 1625 ++++++++++++++- .../service/PartyCommandServiceTest.java | 1852 +++++++++++++++++ .../party/service/PartyQueryServiceTest.java | 892 +++++++- 21 files changed, 4381 insertions(+), 186 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java index 6876cee7a..adf7ae4b5 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryService.java @@ -391,7 +391,7 @@ private void validatePartyIsActive(Party party) { private boolean checkManagerPermission(Party party, Member member) { return memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), member.getId(), Role.party_MANAGER); + party.getId(), member.getId(), Role.PARTY_MANAGER); } private ExerciseDetailDTO.ExerciseInfo createExerciseInfo(Exercise exercise) { diff --git a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java index a81174b3f..fabda2ec8 100644 --- a/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java +++ b/src/main/java/umc/cockple/demo/domain/exercise/service/ExerciseValidator.java @@ -90,9 +90,9 @@ private void validatePartyIsActive(Party party) { private void validateSubManagerPermission(Long memberId, Party party) { boolean isOwner = party.getOwnerId().equals(memberId); boolean isManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), memberId, Role.party_MANAGER); + party.getId(), memberId, Role.PARTY_MANAGER); boolean isSubManager = memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), memberId, Role.party_SUBMANAGER); + party.getId(), memberId, Role.PARTY_SUBMANAGER); if (!isOwner && !isManager && !isSubManager) throw new ExerciseException(ExerciseErrorCode.INSUFFICIENT_PERMISSION); diff --git a/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java b/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java index 8d3cc6794..cf85ec7cd 100644 --- a/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java +++ b/src/main/java/umc/cockple/demo/domain/member/domain/MemberParty.java @@ -47,7 +47,7 @@ public static MemberParty createOwner(Member member, Party party) { return MemberParty.builder() .member(member) .party(party) - .role(Role.party_MANAGER) + .role(Role.PARTY_MANAGER) .joinedAt(LocalDateTime.now()) .status(ACTIVE) .build(); @@ -57,20 +57,20 @@ public static MemberParty create(Party party, Member member) { return MemberParty.builder() .member(member) .party(party) - .role(Role.party_MEMBER) + .role(Role.PARTY_MEMBER) .joinedAt(LocalDateTime.now()) .status(ACTIVE) .build(); } public boolean isLeader() { - if (this.role == Role.party_MANAGER) return true; + if (this.role == Role.PARTY_MANAGER) return true; return false; } public boolean isViceLeader() { - if (this.role == Role.party_SUBMANAGER) return true; + if (this.role == Role.PARTY_SUBMANAGER) return true; return false; } diff --git a/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java b/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java index 4f87a3a86..e331e6c68 100644 --- a/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java +++ b/src/main/java/umc/cockple/demo/domain/party/converter/PartyConverter.java @@ -211,9 +211,9 @@ public PartyMemberSuggestionDTO.Response toPartyMemberSuggestionDTO(Member membe private int getRolePriority(String role) { return switch (role) { - case "party_MANAGER" -> 0; // 모임장 역할 - case "party_SUBMANAGER" -> 1; // 부모임장 - case "party_MEMBER" -> 2; // 일반 멤버 + case "PARTY_MANAGER" -> 0; // 모임장 역할 + case "PARTY_SUBMANAGER" -> 1; // 부모임장 + case "PARTY_MEMBER" -> 2; // 일반 멤버 default -> 99; }; } diff --git a/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java b/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java index e844ab093..abc159c4d 100644 --- a/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java +++ b/src/main/java/umc/cockple/demo/domain/party/exception/PartyErrorCode.java @@ -24,10 +24,10 @@ public enum PartyErrorCode implements BaseErrorCode { INVALID_ORDER_TYPE(HttpStatus.BAD_REQUEST, "PARTY106", "유효하지 않은 정렬 기준입니다. (최신순, 오래된 순, 운동 많은 순 중 하나여야 합니다.)"), INVALID_KEYWORD(HttpStatus.BAD_REQUEST, "PARTY107", "유효하지 않은 키워드입니다."), MALE_LEVEL_NOT_NEEDED(HttpStatus.BAD_REQUEST, "PARTY108", "여복 모임은 남자 급수를 설정할 수 없습니다."), - INVALID_ROLE_VALUE(HttpStatus.BAD_REQUEST, "PARTY411", "유효하지 않은 역할 값입니다. (party_SUBMANAGER 또는 party_MEMBER를 입력해주세요.)"), + INVALID_ROLE_VALUE(HttpStatus.BAD_REQUEST, "PARTY411", "유효하지 않은 역할 값입니다. (PARTY_SUBMANAGER 또는 PARTY_MEMBER를 입력해주세요.)"), PARTY_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY201", "존재하지 않는 모임입니다."), - JoinRequest_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY202", "존재하지 않는 가입신청입니다."), + JOIN_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY202", "존재하지 않는 가입신청입니다."), JOIN_REQUEST_PARTY_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY203", "해당 모임에서 존재하지 않는 가입신청입니다."), NOT_MEMBER(HttpStatus.BAD_REQUEST, "PARTY204", "해당 모임의 멤버가 아닙니다."), INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTY205", "존재하지 않는 모임 초대입니다."), diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java index f3624188a..9616b587a 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java @@ -190,7 +190,7 @@ public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemb // 모임장 권한 검증 validateOwnerPermission(party, currentMemberId); // 대상이 모임장인 경우 변경 불가 - if (targetMemberParty.getRole() == Role.party_MANAGER) { + if (targetMemberParty.getRole() == Role.PARTY_MANAGER) { throw new PartyException(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER); } // 이미 같은 역할인 경우 @@ -199,10 +199,10 @@ public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemb } // SUBOWNER 지정 시, 기존 부모임장 자동 해제 - if (newRole == Role.party_SUBMANAGER) { - memberPartyRepository.findByPartyIdAndRole(partyId, Role.party_SUBMANAGER) + if (newRole == Role.PARTY_SUBMANAGER) { + memberPartyRepository.findByPartyIdAndRole(partyId, Role.PARTY_SUBMANAGER) .ifPresent(mp -> { - mp.changeRole(Role.party_MEMBER); + mp.changeRole(Role.PARTY_MEMBER); createRoleNotification(partyId, NotificationTarget.PARTY_SUBOWNER_RELEASED, mp.getMember().getNickname()); }); @@ -212,7 +212,7 @@ public void updateMemberRole(Long partyId, Long targetMemberId, Long currentMemb targetMemberParty.changeRole(newRole); // 알림 발송 (전체 멤버 대상) - NotificationTarget notifTarget = (newRole == Role.party_SUBMANAGER) + NotificationTarget notifTarget = (newRole == Role.PARTY_SUBMANAGER) ? NotificationTarget.PARTY_SUBOWNER_ASSIGNED : NotificationTarget.PARTY_SUBOWNER_RELEASED; createRoleNotification(partyId, notifTarget, targetMember.getNickname()); @@ -335,7 +335,7 @@ public void addKeyword(Long partyId, Long memberId, PartyKeywordDTO.Request requ //가입신청 조회 private PartyJoinRequest findJoinRequestOrThrow(Long requestId) { return partyJoinRequestRepository.findById(requestId) - .orElseThrow(() -> new PartyException(PartyErrorCode.JoinRequest_NOT_FOUND)); + .orElseThrow(() -> new PartyException(PartyErrorCode.JOIN_REQUEST_NOT_FOUND)); } private PartyInvitation findInvitationOrThrow(Long invitationId) { @@ -387,7 +387,7 @@ private void validateIsNotOwner(Party party, Long memberId) { // 부모임장은 권한이 없음을 검증 private void validateIsNotSubOwner(Party party, Long memberId) { - memberPartyRepository.findByPartyIdAndRole(party.getId(), Role.party_SUBMANAGER) + memberPartyRepository.findByPartyIdAndRole(party.getId(), Role.PARTY_SUBMANAGER) .ifPresent(mp -> { if (mp.getMember().getId().equals(memberId)) { throw new PartyException(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER); @@ -427,7 +427,7 @@ private void validateRemovalPermission(Party party, Member remover, MemberParty if (remover.getId().equals(memberPartyToRemove.getMember().getId())) { //부모임장인 경우에만 가능 MemberParty removerMemberParty = findMemberPartyOrThrow(party, remover); - if (removerMemberParty.getRole() == Role.party_SUBMANAGER) { + if (removerMemberParty.getRole() == Role.PARTY_SUBMANAGER) { return; } else { throw new PartyException(PartyErrorCode.CANNOT_REMOVE_SELF); @@ -439,11 +439,11 @@ private void validateRemovalPermission(Party party, Member remover, MemberParty Role removerRole = removerMemberParty.getRole(); Role targetRole = memberPartyToRemove.getRole(); //모임장은 모두 삭제 가능 - if (removerRole == Role.party_MANAGER) { + if (removerRole == Role.PARTY_MANAGER) { return; } //부모임장은 일반 멤버만 삭제 가능 (모임장을 삭제하려할 경우 권한 없음) - if (removerRole == Role.party_SUBMANAGER && targetRole == Role.party_MEMBER) { + if (removerRole == Role.PARTY_SUBMANAGER && targetRole == Role.PARTY_MEMBER) { return; } //일반 멤버는 권한 없음 diff --git a/src/main/java/umc/cockple/demo/global/enums/Role.java b/src/main/java/umc/cockple/demo/global/enums/Role.java index b347e011f..0daea6101 100644 --- a/src/main/java/umc/cockple/demo/global/enums/Role.java +++ b/src/main/java/umc/cockple/demo/global/enums/Role.java @@ -2,9 +2,9 @@ public enum Role { - party_MEMBER, //일반 멤버 + PARTY_MEMBER, //일반 멤버 - party_MANAGER, //모임장 + PARTY_MANAGER, //모임장 - party_SUBMANAGER //부모임장 + PARTY_SUBMANAGER //부모임장 } diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java index 984dfb50f..8ffea1ca0 100644 --- a/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java @@ -63,7 +63,7 @@ void setUp() { PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("경기도", "안산시")); bookmarkParty = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(bookmarkParty, member, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(bookmarkParty, member, Role.PARTY_MANAGER)); bookmarkExercise = exerciseRepository.save(Exercise.builder() .party(bookmarkParty) diff --git a/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java index 0671cf4df..4d3643739 100644 --- a/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/chat/integration/ChatIntegrationTest.java @@ -58,8 +58,8 @@ void setUp() { PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); party = partyRepository.save(PartyFixture.createParty("배드민턴 모임", member.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.party_MANAGER)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, otherMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, otherMember, Role.PARTY_MEMBER)); partyChatRoom = chatRoomRepository.save(ChatFixture.createPartyChatRoom(party)); directChatRoom = chatRoomRepository.save(ChatFixture.createDirectChatRoom()); diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java index fa378c12c..bcaef277e 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java @@ -70,9 +70,9 @@ void setUp() { PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER)); } @AfterEach @@ -606,7 +606,7 @@ void notPartyMember_outsideNotAccepted() throws Exception { void ageNotAllowed() throws Exception { Member youngMember = memberRepository.save( MemberFixture.createMember("어린회원", Gender.MALE, Level.B, 4001L, LocalDate.of(2010, 1, 1))); - memberPartyRepository.save(MemberFixture.createMemberParty(party, youngMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, youngMember, Role.PARTY_MEMBER)); SecurityContextHelper.setAuthentication(youngMember.getId(), youngMember.getNickname()); diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index 6e8b1b8e7..de9e789f5 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; @@ -39,7 +38,6 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.List; -import java.util.Map; import javax.sql.DataSource; @@ -79,9 +77,9 @@ void setUp() { PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER)); } @AfterEach @@ -139,7 +137,7 @@ class Success { .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE")) .andExpect(jsonPath("$.data.participants.list[0].level").isString()) .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER")) - .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("party_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("PARTY_MEMBER")) .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)) .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) .andExpect(jsonPath("$.data.waiting.manCount").value(0)) @@ -147,7 +145,7 @@ class Success { .andExpect(jsonPath("$.data.waiting.list[0].name").value(subManager.getMemberName())) .andExpect(jsonPath("$.data.waiting.list[0].gender").value("FEMALE")) .andExpect(jsonPath("$.data.waiting.list[0].participantType").value("PARTY_MEMBER")) - .andExpect(jsonPath("$.data.waiting.list[0].partyPosition").value("party_SUBMANAGER")) + .andExpect(jsonPath("$.data.waiting.list[0].partyPosition").value("PARTY_SUBMANAGER")) .andExpect(jsonPath("$.data.waiting.list[0].isWithdrawn").value(false)); } @@ -277,13 +275,13 @@ class Success { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.participants.list[0].name").value(manager.getMemberName())) .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER")) - .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("party_MANAGER")) + .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("PARTY_MANAGER")) .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())) .andExpect(jsonPath("$.data.participants.list[1].participantType").value("PARTY_MEMBER")) - .andExpect(jsonPath("$.data.participants.list[1].partyPosition").value("party_SUBMANAGER")) + .andExpect(jsonPath("$.data.participants.list[1].partyPosition").value("PARTY_SUBMANAGER")) .andExpect(jsonPath("$.data.participants.list[2].name").value(normalMember.getMemberName())) .andExpect(jsonPath("$.data.participants.list[2].participantType").value("PARTY_MEMBER")) - .andExpect(jsonPath("$.data.participants.list[2].partyPosition").value("party_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[2].partyPosition").value("PARTY_MEMBER")) .andExpect(jsonPath("$.data.participants.list[3].name").value(outsider.getMemberName())) .andExpect(jsonPath("$.data.participants.list[3].participantType").value("EXTERNAL_PARTICIPANT")) .andExpect(jsonPath("$.data.participants.list[3].partyPosition").value(nullValue())) @@ -1501,7 +1499,7 @@ void setUp() { filteredParty = partyRepository.save(filteredParty); filteredParty.addLevel(Gender.MALE, Level.B); filteredParty = partyRepository.save(filteredParty); - memberPartyRepository.save(MemberFixture.createMemberParty(filteredParty, manager, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(filteredParty, manager, Role.PARTY_MANAGER)); startDate = LocalDate.of(2026, 3, 23); endDate = LocalDate.of(2026, 4, 5); diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java index cb51d226d..64e04dced 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java @@ -72,7 +72,7 @@ void setUp() { partyRepository.save(party); // 모임장을 모임 멤버로 등록 - memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER)); } @AfterEach @@ -155,7 +155,7 @@ class Success { void 소속된_모임의_운동은_추천되지_않는다() throws Exception { // given - outsider를 모임에 가입시킴 memberPartyRepository.save( - MemberFixture.createMemberParty(party, outsider, Role.party_MEMBER)); + MemberFixture.createMemberParty(party, outsider, Role.PARTY_MEMBER)); exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관")); diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java index af8355184..c317929bd 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseLifecycleServiceTest.java @@ -128,9 +128,9 @@ void subManagerCreatesExercise_success() { Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); ReflectionTestUtils.setField(subManager, "id", 2L); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER)) .willReturn(true); Exercise savedExercise = Exercise.builder() @@ -175,9 +175,9 @@ void normalMember_throwsException() { Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); ReflectionTestUtils.setField(normalMember, "id", 2L); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER)) .willReturn(false); assertThatThrownBy(() -> @@ -278,9 +278,9 @@ void subManagerDeletesExercise_success() { Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); ReflectionTestUtils.setField(subManager, "id", 2L); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER)) .willReturn(true); // when @@ -303,9 +303,9 @@ void normalMember_throwsException() { Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); ReflectionTestUtils.setField(normalMember, "id", 2L); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER)) .willReturn(false); assertThatThrownBy(() -> @@ -384,9 +384,9 @@ void subManagerUpdatesExercise_success() { Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); ReflectionTestUtils.setField(subManager, "id", 2L); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER)) .willReturn(true); Exercise savedExercise = Exercise.builder() @@ -418,9 +418,9 @@ void normalMember_throwsException() { Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L); ReflectionTestUtils.setField(normalMember, "id", 2L); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER)) .willReturn(false); assertThatThrownBy(() -> diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java index 68ce83bee..62be82d77 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseParticipationServiceTest.java @@ -341,9 +341,9 @@ void subManagerCancelsMemberParticipation_success() { ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), subManager.getId(), Role.PARTY_SUBMANAGER)) .willReturn(true); given(memberRepository.findById(participant.getId())).willReturn(Optional.of(participant)); given(memberExerciseRepository.findByExerciseAndMember(exercise, participant)) @@ -392,9 +392,9 @@ void normalMember_throwsException() { ExerciseCancelDTO.ByManagerRequest request = new ExerciseCancelDTO.ByManagerRequest(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_MANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) .willReturn(false); - given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.party_SUBMANAGER)) + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole(party.getId(), normalMember.getId(), Role.PARTY_SUBMANAGER)) .willReturn(false); assertThatThrownBy(() -> diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 117fd8d8f..1a8c53480 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -142,7 +142,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); // when @@ -169,7 +169,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), subManager.getId(), Role.party_MANAGER)) + party.getId(), subManager.getId(), Role.PARTY_MANAGER)) .willReturn(false); // when @@ -196,7 +196,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), normalMember.getId(), Role.party_MANAGER)) + party.getId(), normalMember.getId(), Role.PARTY_MANAGER)) .willReturn(false); // when @@ -223,7 +223,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), outsider.getId(), Role.party_MANAGER)) + party.getId(), outsider.getId(), Role.PARTY_MANAGER)) .willReturn(false); // when @@ -253,7 +253,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(withdrawnMember.getId()))) @@ -278,7 +278,7 @@ class Success { MemberExercise memberExercise = MemberFixture.createMemberExercise(activeMember, exercise); - MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.party_MEMBER); + MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.PARTY_MEMBER); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -289,7 +289,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(activeMember.getId()))) @@ -321,7 +321,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of(guest)); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberRepository.findMemberNamesByIds(any())) .willReturn(Map.of(manager.getId(), "모임장")); @@ -366,9 +366,9 @@ class Success { ReflectionTestUtils.setField(guest, "id", 71L); ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now().minusMinutes(1)); - MemberParty managerParty = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER); - MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER); - MemberParty memberParty = MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER); + MemberParty managerParty = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER); + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); + MemberParty memberParty = MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -379,7 +379,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of(guest)); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(manager.getId(), subManager.getId(), normalMember.getId(), outsider.getId()))) @@ -398,9 +398,9 @@ class Success { ExerciseDetailDTO.ParticipantInfo::participantType, ExerciseDetailDTO.ParticipantInfo::partyPosition) .containsExactly( - tuple("모임장", "PARTY_MEMBER", "party_MANAGER"), - tuple("부모임장", "PARTY_MEMBER", "party_SUBMANAGER"), - tuple("일반멤버", "PARTY_MEMBER", "party_MEMBER"), + tuple("모임장", "PARTY_MEMBER", "PARTY_MANAGER"), + tuple("부모임장", "PARTY_MEMBER", "PARTY_SUBMANAGER"), + tuple("일반멤버", "PARTY_MEMBER", "PARTY_MEMBER"), tuple("외부회원", "EXTERNAL_PARTICIPANT", null), tuple("게스트", "GUEST", null) ); @@ -424,8 +424,8 @@ class Success { MemberExercise second = MemberFixture.createMemberExercise(secondMember, exercise); ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); - MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.party_MEMBER); - MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.party_MEMBER); + MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.PARTY_MEMBER); + MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.PARTY_MEMBER); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -436,7 +436,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(firstMember.getId(), secondMember.getId()))) @@ -468,7 +468,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of(guest)); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberRepository.findMemberNamesByIds(any())) .willReturn(Map.of(manager.getId(), "모임장")); @@ -500,8 +500,8 @@ class Success { MemberExercise second = MemberFixture.createMemberExercise(secondMember, exercise); ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); - MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.party_MEMBER); - MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.party_MEMBER); + MemberParty firstParty = MemberFixture.createMemberParty(party, firstMember, Role.PARTY_MEMBER); + MemberParty secondParty = MemberFixture.createMemberParty(party, secondMember, Role.PARTY_MEMBER); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -512,7 +512,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(firstMember.getId(), secondMember.getId()))) @@ -549,8 +549,8 @@ class Success { MemberExercise second = MemberFixture.createMemberExercise(femaleMember, exercise); ReflectionTestUtils.setField(second, "createdAt", LocalDateTime.now()); - MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.party_MEMBER); - MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.party_MEMBER); + MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.PARTY_MEMBER); + MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.PARTY_MEMBER); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -561,7 +561,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(maleMember.getId(), femaleMember.getId()))) @@ -594,8 +594,8 @@ class Success { MemberExercise femaleExercise = MemberFixture.createMemberExercise(femaleMember, exercise); ReflectionTestUtils.setField(femaleExercise, "createdAt", LocalDateTime.now()); - MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.party_MEMBER); - MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.party_MEMBER); + MemberParty maleParty = MemberFixture.createMemberParty(party, maleMember, Role.PARTY_MEMBER); + MemberParty femaleParty = MemberFixture.createMemberParty(party, femaleMember, Role.PARTY_MEMBER); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -606,7 +606,7 @@ class Success { given(guestRepository.findByExerciseId(exercise.getId())) .willReturn(List.of()); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( - party.getId(), manager.getId(), Role.party_MANAGER)) + party.getId(), manager.getId(), Role.PARTY_MANAGER)) .willReturn(true); given(memberPartyRepository.findMemberRolesByPartyAndMembers( party.getId(), List.of(maleMember.getId(), femaleMember.getId()))) diff --git a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java index bba271356..28d577edc 100644 --- a/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/integration/MemberIntegrationTest.java @@ -112,7 +112,7 @@ class Failure { void manager_cannotWithdraw() throws Exception { PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_MANAGER)); SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); @@ -127,7 +127,7 @@ void manager_cannotWithdraw() throws Exception { void subManager_cannotWithdraw() throws Exception { PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); Party party = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.party_SUBMANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, member, Role.PARTY_SUBMANAGER)); SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); @@ -204,8 +204,8 @@ class Success { .build()); // 모임 2개 - memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); - memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER)); SecurityContextHelper.setAuthentication(freshMember.getId(), freshMember.getNickname()); @@ -322,8 +322,8 @@ void getMyProfile_success() throws Exception { .build()); // 모임 2개, 운동 2개, 키워드 2개 - memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); - memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.party_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER)); + memberPartyRepository.save(MemberFixture.createMemberParty(null, freshMember, Role.PARTY_MEMBER)); memberExerciseRepository.save(MemberExercise.builder() .member(freshMember) diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java index 290c391a4..f74620c03 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberCommandServiceTest.java @@ -703,7 +703,7 @@ class Failure { void 활성_모임의_모임장이면_MANAGER_CANNOT_LEAVE_예외를_던진다() { // given MemberParty leaderParty = MemberParty.builder() - .role(Role.party_MANAGER) + .role(Role.PARTY_MANAGER) .status(MemberPartyStatus.ACTIVE) .joinedAt(LocalDateTime.now()) .build(); @@ -723,7 +723,7 @@ class Failure { void 활성_모임의_부모임장이면_SUBMANAGER_CANNOT_LEAVE_예외를_던진다() { // given MemberParty subManagerParty = MemberParty.builder() - .role(Role.party_SUBMANAGER) + .role(Role.PARTY_SUBMANAGER) .status(MemberPartyStatus.ACTIVE) .joinedAt(LocalDateTime.now()) .build(); @@ -743,7 +743,7 @@ class Failure { void 비활성_모임의_모임장이면_탈퇴가_가능하다() { // given: BANNED 상태의 모임이라면 탈퇴 검증을 통과해야 한다 MemberParty bannedParty = MemberParty.builder() - .role(Role.party_MANAGER) + .role(Role.PARTY_MANAGER) .status(MemberPartyStatus.BANNED) .joinedAt(LocalDateTime.now()) .build(); diff --git a/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java index b7c521268..9c1eb0a16 100644 --- a/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/member/service/MemberQueryServiceTest.java @@ -125,8 +125,8 @@ class Success { @DisplayName("참여한_모임_수가_올바르게_반환된다") void 참여한_모임_수가_올바르게_반환된다() { // given - member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.party_MEMBER)); - member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.party_MEMBER)); + member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.PARTY_MEMBER)); + member.getMemberParties().add(MemberFixture.createMemberParty(null, member, umc.cockple.demo.global.enums.Role.PARTY_MEMBER)); given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); diff --git a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java index 87f8f8245..25fe51839 100644 --- a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java @@ -1,18 +1,39 @@ package umc.cockple.demo.domain.party.integration; -import org.junit.jupiter.api.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.domain.party.domain.Party; import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.domain.PartyInvitation; +import umc.cockple.demo.domain.party.domain.PartyJoinRequest; +import umc.cockple.demo.domain.party.dto.*; +import umc.cockple.demo.domain.party.enums.*; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyInvitationRepository; +import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository; import umc.cockple.demo.domain.party.repository.PartyRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; @@ -24,20 +45,42 @@ import umc.cockple.demo.support.fixture.PartyFixture; import java.time.LocalDate; +import java.util.List; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@Transactional class PartyIntegrationTest extends IntegrationTestBase { - @Autowired MockMvc mockMvc; - @Autowired MemberRepository memberRepository; - @Autowired PartyRepository partyRepository; - @Autowired MemberPartyRepository memberPartyRepository; - @Autowired PartyAddrRepository partyAddrRepository; - @Autowired ExerciseRepository exerciseRepository; - @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired + MockMvc mockMvc; + @Autowired + MemberRepository memberRepository; + @Autowired + PartyRepository partyRepository; + @Autowired + MemberPartyRepository memberPartyRepository; + @Autowired + PartyAddrRepository partyAddrRepository; + @Autowired + ExerciseRepository exerciseRepository; + @Autowired + MemberExerciseRepository memberExerciseRepository; + @Autowired + MemberAddrRepository memberAddrRepository; + @Autowired + ChatRoomRepository chatRoomRepository; + @Autowired + ChatRoomMemberRepository chatRoomMemberRepository; + @Autowired + PartyJoinRequestRepository partyJoinRequestRepository; + @Autowired + PartyInvitationRepository partyInvitationRepository; + @Autowired + ObjectMapper objectMapper; private Member manager; private Member normalMember; @@ -45,86 +88,1564 @@ class PartyIntegrationTest extends IntegrationTestBase { @BeforeEach void setUp() { - manager = memberRepository.save(MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L)); + // 매니저 및 주소 정보 생성 + manager = memberRepository.save(MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 1, 1))); + memberAddrRepository.save(MemberAddr.builder() + .member(manager) + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("테헤란로") + .latitude(37.5) + .longitude(127.0) + .isMain(true) + .build()); + + // 일반 멤버 생성 normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 1002L)); + // 모임 및 주소 정보 생성 PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); - memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER)); + // 모임 멤버 생성 + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER)); + + // 채팅방 생성 + ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.createPartyChatRoom(party)); + chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, manager)); + chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, normalMember)); + + // 추천 조회용 모임 (manager의 조건에 맞춤) + Party suggestedParty = PartyFixture.createParty("추천 모임", normalMember.getId(), addr); + suggestedParty.addLevel(Gender.MALE, Level.A); + partyRepository.save(suggestedParty); SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); } - @AfterEach - void tearDown() { - memberExerciseRepository.deleteAll(); - exerciseRepository.deleteAll(); - memberPartyRepository.deleteAll(); - partyRepository.deleteAll(); - partyAddrRepository.deleteAll(); - memberRepository.deleteAll(); - } @Nested @DisplayName("GET /api/parties/{partyId}/members - 모임 멤버 조회") class GetPartyMembers { @Test - @DisplayName("200 - 멤버 목록과 마지막 운동일을 정상 반환한다") - void success_withLastExerciseDate() throws Exception { - Exercise exercise = exerciseRepository.save( - ExerciseFixture.createExercise(party, LocalDate.of(2025, 1, 10))); + @DisplayName("200 - 멤버 목록을 역할, 성별 통계 및 마지막 운동일과 함께 조회한다") + void success_getPartyMembers() throws Exception { + // 부모임장 추가 + Member subManager = memberRepository.save(MemberFixture.createMember("부매니저", Gender.MALE, Level.A, 1003L)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER)); + + // 운동 기록 추가 + Exercise exercise = exerciseRepository.save(ExerciseFixture.createExercise(party, LocalDate.of(2025, 1, 10))); memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); mockMvc.perform(get("/api/parties/{partyId}/members", party.getId())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.summary.totalCount").value(2)) - .andExpect(jsonPath("$.data.summary.maleCount").value(1)) + .andExpect(jsonPath("$.data.summary.totalCount").value(3)) + .andExpect(jsonPath("$.data.summary.maleCount").value(2)) .andExpect(jsonPath("$.data.summary.femaleCount").value(1)) - // 첫 번째 멤버(매니저) 전체 필드 검증 - .andExpect(jsonPath("$.data.members[0].memberId").value(manager.getId())) - .andExpect(jsonPath("$.data.members[0].nickname").value("매니저")) - .andExpect(jsonPath("$.data.members[0].profileImageUrl").doesNotExist()) - .andExpect(jsonPath("$.data.members[0].role").value("party_MANAGER")) - .andExpect(jsonPath("$.data.members[0].gender").value("MALE")) - .andExpect(jsonPath("$.data.members[0].level").value("A조")) + .andExpect(jsonPath("$.data.members[0].role").value("PARTY_MANAGER")) .andExpect(jsonPath("$.data.members[0].isMe").value(true)) - .andExpect(jsonPath("$.data.members[0].lastExerciseDate").doesNotExist()) - // 두 번째 멤버(일반멤버) 마지막 운동일 검증 - .andExpect(jsonPath("$.data.members[1].lastExerciseDate").value("2025-01-10")); + .andExpect(jsonPath("$.data.members[1].role").value("PARTY_SUBMANAGER")) + .andExpect(jsonPath("$.data.members[2].role").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.members[2].lastExerciseDate").value("2025-01-10")); } @Test - @DisplayName("200 - 운동 기록이 없는 멤버의 lastExerciseDate는 null이다") - void success_noExerciseHistory() throws Exception { + @DisplayName("404 - 존재하지 않는 파티면 에러를 반환한다") + void fail_getPartyMembers_partyNotFound() throws Exception { + mockMvc.perform(get("/api/parties/{partyId}/members", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("400 - 비활성화된 파티면 에러를 반환한다") + void fail_getPartyMembers_partyInactive() throws Exception { + party.delete(); + partyRepository.save(party); + mockMvc.perform(get("/api/parties/{partyId}/members", party.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())); + } + } + + @Nested + @DisplayName("DELETE /api/parties/{partyId}/members/my - 모임 탈퇴") + class LeaveParty { + + @Test + @DisplayName("200 - 일반 멤버가 모임을 성공적으로 탈퇴한다") + void success_leaveParty() throws Exception { + // DB에서 최신 정보 보장 + Member member = memberRepository.findById(normalMember.getId()).orElseThrow(); + Party targetParty = partyRepository.findById(party.getId()).orElseThrow(); + + // normalMember 세션으로 설정 + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/members/my", targetParty.getId())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.summary.totalCount").value(2)) - .andExpect(jsonPath("$.data.members[0].lastExerciseDate").isEmpty()) - .andExpect(jsonPath("$.data.members[1].lastExerciseDate").isEmpty()); + .andExpect(jsonPath("$.code").value("COMMON200")); + + // DB에서 제거되었는지 확인 + boolean exists = memberPartyRepository.existsByPartyAndMember(targetParty, member); + assertThat(exists).isFalse(); } @Test - @DisplayName("404 - 존재하지 않는 파티면 에러를 반환한다") - void fail_partyNotFound() throws Exception { - mockMvc.perform(get("/api/parties/{partyId}/members", 999L)) + @DisplayName("403 - 모임장은 탈퇴할 수 없다") + void fail_leaveParty_owner() throws Exception { + // manager(모임장) 세션으로 설정 + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/members/my", party.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_ACTION_FOR_OWNER.getCode())); + } + + @Test + @DisplayName("403 - 부모임장은 탈퇴할 수 없다") + void fail_leaveParty_subOwner() throws Exception { + // 부모임장 생성 및 가입 + Member subManager = memberRepository.save(MemberFixture.createMember("부매니저", Gender.MALE, Level.A, 3001L)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER)); + + // 부모임장 세션으로 설정 + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/members/my", party.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER.getCode())); + } + + @Test + @DisplayName("400 - 해당 모임의 멤버가 아니면 탈퇴할 수 없다") + void fail_leaveParty_notMember() throws Exception { + // 가입하지 않은 새로운 멤버 생성 + Member nonMember = memberRepository.save(MemberFixture.createMember("외부인", Gender.MALE, Level.A, 4002L)); + SecurityContextHelper.setAuthentication(nonMember.getId(), nonMember.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/members/my", party.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.NOT_MEMBER.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티에서 탈퇴 시도 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_leaveParty_partyNotFound() throws Exception { + mockMvc.perform(delete("/api/parties/{partyId}/members/my", 9999L)) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_NOT_FOUND.getMessage())); + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("GET /api/my/parties - 내 모임 조회") + class GetMyParties { + + Party party2; + Party party3; + + @BeforeEach + void setUpMyParties() { + PartyAddr addr = partyAddrRepository.findAll().get(0); + + // party: 1번째 생성, 운동 횟수 10 + ReflectionTestUtils.setField(party, "exerciseCount", 10); + partyRepository.save(party); + + // party2: 2번째 생성, 운동 횟수 20 + party2 = partyRepository.save(PartyFixture.createParty("테스트 모임 2", manager.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party2, manager, Role.PARTY_MANAGER)); + ReflectionTestUtils.setField(party2, "exerciseCount", 20); + partyRepository.save(party2); + + // party3: 3번째 생성, 운동 횟수 5 + party3 = partyRepository.save(PartyFixture.createParty("테스트 모임 3", manager.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party3, manager, Role.PARTY_MANAGER)); + ReflectionTestUtils.setField(party3, "exerciseCount", 5); + partyRepository.save(party3); } @Test - @DisplayName("400 - 비활성화된 파티면 에러를 반환한다") - void fail_partyInactive() throws Exception { + @DisplayName("200 - 사용자가 가입한 모임 목록을 최신순(기본)으로 페이징하여 반환한다") + void success_getMyParties() throws Exception { + mockMvc.perform(get("/api/my/parties") + .param("created", "false") + .param("sort", "최신순") + .param("size", "10") + .param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(party3.getId())) + .andExpect(jsonPath("$.data.content[1].partyId").value(party2.getId())) + .andExpect(jsonPath("$.data.content[2].partyId").value(party.getId())); + } + + @Test + @DisplayName("200 - 사용자가 가입한 모임 목록을 오래된 순으로 페이징하여 반환한다") + void success_getMyParties_oldest() throws Exception { + mockMvc.perform(get("/api/my/parties") + .param("created", "false") + .param("sort", "오래된 순") + .param("size", "10") + .param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.content[1].partyId").value(party2.getId())) + .andExpect(jsonPath("$.data.content[2].partyId").value(party3.getId())); + } + + @Test + @DisplayName("200 - 사용자가 가입한 모임 목록을 운동 많은 순으로 페이징하여 반환한다") + void success_getMyParties_exerciseCount() throws Exception { + mockMvc.perform(get("/api/my/parties") + .param("created", "false") + .param("sort", "운동 많은 순") + .param("size", "10") + .param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(party2.getId())) // 20회 + .andExpect(jsonPath("$.data.content[1].partyId").value(party.getId())) // 10회 + .andExpect(jsonPath("$.data.content[2].partyId").value(party3.getId())); // 5회 + } + + @Test + @DisplayName("200 - 가입한 모임이 없을 경우 빈 목록을 반환한다") + void success_emptyMyParties() throws Exception { + Member newMember = memberRepository.save(MemberFixture.createMember("뉴비", + Gender.MALE, Level.BEGINNER, 3003L)); + SecurityContextHelper.setAuthentication(newMember.getId(), newMember.getNickname()); + + mockMvc.perform(get("/api/my/parties") + .param("created", "false") + .param("sort", "최신순") + .param("size", "10") + .param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content").isEmpty()) + .andExpect(jsonPath("$.data.empty").value(true)); + } + } + + @Nested + @DisplayName("GET /api/my/parties/simple - 내 모임 간략화 조회") + class GetSimpleMyParties { + + Party party2; + Party party3; + + @BeforeEach + void setUpSimpleMyParties() { + PartyAddr addr = partyAddrRepository.findAll().get(0); + + // party2 + party2 = partyRepository.save(PartyFixture.createParty("간략 모임 2", manager.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party2, manager, Role.PARTY_MANAGER)); + + // party3 + party3 = partyRepository.save(PartyFixture.createParty("간략 모임 3", manager.getId(), addr)); + memberPartyRepository.save(MemberFixture.createMemberParty(party3, manager, Role.PARTY_MANAGER)); + } + + @Test + @DisplayName("200 - 사용자가 가입한 모임의 간략화된 목록을 페이징하여 반환한다") + void success_getSimpleMyParties() throws Exception { + mockMvc.perform(get("/api/my/parties/simple") + .param("page", "0") + .param("size", "10") + .param("sort", "createdAt,DESC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(party3.getId())) + .andExpect(jsonPath("$.data.content[1].partyId").value(party2.getId())) + .andExpect(jsonPath("$.data.content[2].partyId").value(party.getId())); + } + + @Test + @DisplayName("200 - 가입한 모임이 없을 경우 빈 목록을 반환한다") + void success_emptySimpleMyParties() throws Exception { + Member newMember = memberRepository.save(MemberFixture.createMember("뉴비", + Gender.MALE, Level.BEGINNER, 3003L)); + SecurityContextHelper.setAuthentication(newMember.getId(), newMember.getNickname()); + + mockMvc.perform(get("/api/my/parties/simple") + .param("page", "0") + .param("size", "10") + .param("sort", "createdAt,DESC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("요청에 성공했습니다.")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content").isEmpty()) + .andExpect(jsonPath("$.data.empty").value(true)); + } + } + + @Nested + @DisplayName("GET /api/my/parties/suggestions - 모임 추천 조회") + class GetRecommendedParties { + + Party recParty1; + Party recParty2; + Party recParty3; + + @BeforeEach + void setUpRecommends() { + PartyAddr addr = partyAddrRepository.findAll().get(0); + + // recParty1: 1번째 생성, 운동 횟수 10 + recParty1 = partyRepository.findAll().stream() + .filter(p -> p.getPartyName().equals("추천 모임")) + .findFirst().orElseThrow(); + ReflectionTestUtils.setField(recParty1, "exerciseCount", 10); + partyRepository.save(recParty1); + + // recParty2: 1번째 생성, 운동 횟수 20 + recParty2 = partyRepository.save(PartyFixture.createParty("추천 모임 2", normalMember.getId(), addr)); + recParty2.addLevel(Gender.MALE, Level.A); // manager 조건에 맞도록 + ReflectionTestUtils.setField(recParty2, "exerciseCount", 20); + partyRepository.save(recParty2); + + // recParty3: 3번째 생성, 운동 횟수 5 + recParty3 = partyRepository.save(PartyFixture.createParty("추천 모임 3", normalMember.getId(), addr)); + recParty3.addLevel(Gender.MALE, Level.A); // manager 조건에 맞도록 + ReflectionTestUtils.setField(recParty3, "exerciseCount", 5); + partyRepository.save(recParty3); + } + + @Test + @DisplayName("200 - Cockple 추천 모드 시 추천된 모임 목록 3개를 반환한다") + void success_getRecommendedParties_cockpleRecommend() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("isCockpleRecommend", "true") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)); + } + + @Test + @DisplayName("200 - 필터 모드 시 조건에 맞는 모임 목록을 운동 많은 순으로 반환한다") + void success_getRecommendedParties_exerciseCount() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("isCockpleRecommend", "false") + .param("sort", "운동 많은 순") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(recParty2.getId())) // 20회 + .andExpect(jsonPath("$.data.content[1].partyId").value(recParty1.getId())) // 10회 + .andExpect(jsonPath("$.data.content[2].partyId").value(recParty3.getId())); // 5회 + } + + @Test + @DisplayName("200 - 필터 모드 시 조건에 맞는 모임 목록을 최신순으로 반환한다") + void success_getRecommendedParties_latest() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("isCockpleRecommend", "false") + .param("addr1", "서울특별시") + .param("addr2", "강남구") + .param("sort", "최신순") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(recParty3.getId())) + .andExpect(jsonPath("$.data.content[1].partyId").value(recParty2.getId())) + .andExpect(jsonPath("$.data.content[2].partyId").value(recParty1.getId())); + } + + @Test + @DisplayName("200 - 필터 모드 시 조건에 맞는 모임 목록을 오래된 순으로 반환한다") + void success_getRecommendedParties_oldest() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("isCockpleRecommend", "false") + .param("sort", "오래된 순") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].partyId").value(recParty1.getId())) + .andExpect(jsonPath("$.data.content[1].partyId").value(recParty2.getId())) + .andExpect(jsonPath("$.data.content[2].partyId").value(recParty3.getId())); + } + + @Test + @DisplayName("200 - 검색 모드 시 모임명으로 검색된 결과를 반환한다") + void success_getRecommendedParties_search() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("search", "추천 모임 2") + .param("isCockpleRecommend", "false") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].partyName").value("추천 모임 2")); + } + + @Test + @DisplayName("400 - 유효하지 않은 정렬 기준 입력 시 INVALID_ORDER_TYPE 에러를 반환한다") + void fail_getRecommendedParties_invalidOrderType() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("isCockpleRecommend", "false") + .param("sort", "잘못된순")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_ORDER_TYPE.getCode())); + } + + @Test + @DisplayName("400 - isCockpleRecommend에 부적절한 타입 입력 시 400 에러를 반환한다") + void fail_getRecommendedParties_invalidBooleanType() throws Exception { + mockMvc.perform(get("/api/my/parties/suggestions") + .param("isCockpleRecommend", "not-boolean")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/parties/{partyId} - 모임 상세 조회") + class GetPartyDetails { + + @Test + @DisplayName("200 - 모임 상세 정보를 정상적으로 조회한다 (비회원 상태)") + void success_getPartyDetails_nonMember() throws Exception { + // 모임에 가입하지 않은 새로운 유저 생성 및 인증 설정 + Member nonMember = memberRepository.save(MemberFixture.createMember("비회원", Gender.MALE, Level.C, 2001L)); + SecurityContextHelper.setAuthentication(nonMember.getId(), nonMember.getNickname()); + + mockMvc.perform(get("/api/parties/{partyId}", party.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.partyId").value(party.getId())) + .andExpect(jsonPath("$.data.memberStatus").value("NOT_MEMBER")) + .andExpect(jsonPath("$.data.hasPendingJoinRequest").value(false)); + } + + @Test + @DisplayName("200 - 모임원인 경우 memberStatus가 MEMBER로 반환된다") + void success_getPartyDetails_member() throws Exception { + // manager는 setUp에서 이미 party의 멤버로 설정됨 + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/parties/{partyId}", party.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberStatus").value("MEMBER")) + .andExpect(jsonPath("$.data.memberRole").value("PARTY_MANAGER")); + } + + @Test + @DisplayName("404 - 존재하지 않는 모임 조회 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_getPartyDetails_partyNotFound() throws Exception { + mockMvc.perform(get("/api/parties/{partyId}", 9999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("400 - 삭제된 모임 조회 시 PARTY_IS_DELETED 에러를 반환한다") + void fail_getPartyDetails_partyDeleted() throws Exception { + // 모임 삭제 (비활성화) party.delete(); partyRepository.save(party); - mockMvc.perform(get("/api/parties/{partyId}/members", party.getId())) + mockMvc.perform(get("/api/parties/{partyId}", party.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())); + } + } + + @Nested + @DisplayName("POST /api/parties/{partyId}/join-requests - 모임 가입 신청") + class CreateJoinRequest { + + @Test + @DisplayName("200 - 가입하지 않은 회원이 모임 가입을 신청한다") + void success_createJoinRequest() throws Exception { + // 가입하지 않은 멤버 생성 + Member applicant = memberRepository.save(MemberFixture.createMember("신청자", Gender.MALE, Level.A, 5001L, LocalDate.of(1995, 1, 1))); + SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/join-requests", party.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON201")); + + // 가입 신청 데이터 확인 + boolean exists = partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, applicant, RequestStatus.PENDING); + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("409 - 이미 가입된 회원이 다시 가입 신청을 한다") + void fail_createJoinRequest_alreadyMember() throws Exception { + // 이미 가입된 normalMember 사용 + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/join-requests", party.getId())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.ALREADY_MEMBER.getCode())); + } + + @Test + @DisplayName("400 - 성별 조건이 맞지 않는 모임에 신청한다") + void fail_createJoinRequest_genderMismatch() throws Exception { + // 여복 모임 생성 + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울", "강남")); + Party womenParty = partyRepository.save(Party.builder() + .partyName("여복 전용 모임") + .partyType(ParticipationType.WOMEN_DOUBLES) + .status(PartyStatus.ACTIVE) + .ownerId(manager.getId()) + .partyAddr(addr) + .minBirthYear(1900) + .maxBirthYear(2099) + .activityTime(ActivityTime.MORNING) + .designatedCock("테스트콕") + .exerciseCount(0) + .price(0) + .joinPrice(0) + .build()); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/join-requests", womenParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.GENDER_NOT_MATCH.getCode())); + } + + @Test + @DisplayName("409 - 이미 대기중인 가입 신청이 있는 상태에서 다시 신청하면 JOIN_REQUEST_ALREADY_EXISTS 에러를 반환한다") + void fail_createJoinRequest_alreadyExists() throws Exception { + // 가입하지 않은 멤버 생성 + Member applicant = memberRepository.save(MemberFixture.createMember("신청자2", Gender.MALE, Level.A, 5002L, LocalDate.of(1995, 1, 1))); + + // 기존 가입 신청 추가 + PartyJoinRequest joinRequest = PartyJoinRequest.create(applicant, party); + partyJoinRequestRepository.save(joinRequest); + + SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/join-requests", party.getId())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.JOIN_REQUEST_ALREADY_EXISTS.getCode())); + } + + @Test + @DisplayName("400 - 삭제된(비활성화된) 모임에 가입 신청하면 PARTY_IS_DELETED 에러를 반환한다") + void fail_createJoinRequest_partyDeleted() throws Exception { + // 파티 생성 후 삭제 + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울", "금천")); + Party deletedParty = partyRepository.save(PartyFixture.createParty("삭제된 모임", manager.getId(), addr)); + deletedParty.delete(); + partyRepository.save(deletedParty); + + Member applicant = memberRepository.save(MemberFixture.createMember("신청자3", Gender.MALE, Level.A, 5003L)); + SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/join-requests", deletedParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티에 가입 신청하면 PARTY_NOT_FOUND 에러를 반환한다") + void fail_createJoinRequest_partyNotFound() throws Exception { + Member applicant = memberRepository.save(MemberFixture.createMember("신청자4", Gender.MALE, Level.A, 5004L)); + SecurityContextHelper.setAuthentication(applicant.getId(), applicant.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/join-requests", 9999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("PATCH /api/parties/{partyId} - 모임 정보 수정") + class UpdateParty { + + @Test + @DisplayName("200 - 모임장이 유효한 데이터로 모임 정보를 정상적으로 수정한다") + void success_updateParty() throws Exception { + // given + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityDay(List.of("월", "수")) + .activityTime("오전") + .designatedCock("수정된 콕") + .joinPrice(2000) + .price(15000) + .content("수정된 내용입니다.") + .build(); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}", party.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + Party updatedParty = partyRepository.findById(party.getId()).orElseThrow(); + assertThat(updatedParty.getDesignatedCock()).isEqualTo("수정된 콕"); + assertThat(updatedParty.getJoinPrice()).isEqualTo(2000); + assertThat(updatedParty.getPrice()).isEqualTo(15000); + assertThat(updatedParty.getContent()).isEqualTo("수정된 내용입니다."); + } + + @Test + @DisplayName("400 - 필수 필드(activityDay, activityTime) 누락 시 에러를 반환한다") + void fail_updateParty_missingRequiredFields() throws Exception { + // given + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityDay(null) + .activityTime("") + .build(); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}", party.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400_VALIDATION")); + } + + @Test + @DisplayName("403 - 모임장이 아닌 일반 멤버가 수정을 시도하면 INSUFFICIENT_PERMISSION 에러를 반환한다") + void fail_updateParty_notOwner() throws Exception { + // given + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityDay(List.of("토", "일")) + .activityTime("오후") + .build(); + + // 일반 멤버로 세션 설정 + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}", party.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티 수정 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_updateParty_partyNotFound() throws Exception { + // given + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityDay(List.of("월")) + .activityTime("오전") + .build(); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}", 9999L) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("PATCH /api/parties/{partyId}/status - 모임 삭제") + class DeleteParty { + + @Test + @DisplayName("200 - 모임장이 모임을 성공적으로 삭제(비활성화)한다") + void success_deleteParty() throws Exception { + // given + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + Party deletedParty = partyRepository.findById(party.getId()).orElseThrow(); + assertThat(deletedParty.getStatus()).isEqualTo(PartyStatus.INACTIVE); + } + + @Test + @DisplayName("403 - 모임장이 아닌 멤버가 삭제를 시도하면 INSUFFICIENT_PERMISSION 예외를 반환한다") + void fail_deleteParty_notOwner() throws Exception { + // given + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("400 - 이미 삭제된 모임을 다시 삭제 시도하면 PARTY_IS_DELETED 예외를 반환한다") + void fail_deleteParty_partyDeleted() throws Exception { + // given + party.delete(); // 상태 INACTIVE 변경 + partyRepository.save(party); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티 삭제 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_deleteParty_partyNotFound() throws Exception { + mockMvc.perform(patch("/api/parties/{partyId}/status", 9999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("DELETE /api/parties/{partyId}/members/{memberId} - 모임 멤버 삭제") + class RemoveMember { + + @Test + @DisplayName("200 - 모임장이 일반 멤버를 성공적으로 강퇴한다") + void success_removeMember() throws Exception { + // given + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), normalMember.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + boolean exists = memberPartyRepository.existsByPartyAndMember(party, normalMember); + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("403 - 모임장이 아닌 멤버가 삭제를 시도하면 INSUFFICIENT_PERMISSION 에러를 반환한다") + void fail_removeMember_notOwner() throws Exception { + // given + Member someoneElse = memberRepository.save(MemberFixture.createMember("다른멤버", Gender.MALE, Level.B, 1010L)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, someoneElse, Role.PARTY_MEMBER)); + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), someoneElse.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("400 - 모임장이 자기 자신을 강퇴하려 할 경우 CANNOT_REMOVE_SELF 에러를 반환한다") + void fail_removeMember_selfAsManager() throws Exception { + // given + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), manager.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.CANNOT_REMOVE_SELF.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티의 멤버 삭제 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_removeMember_partyNotFound() throws Exception { + mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", 9999L, normalMember.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 멤버를 강퇴하려 할 때 MEMBER_NOT_FOUND 에러를 반환한다") + void fail_removeMember_memberNotFound() throws Exception { + mockMvc.perform(delete("/api/parties/{partyId}/members/{memberId}", party.getId(), 9999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("GET /api/parties/{partyId}/join-requests - 모임 가입 신청 조회") + class GetJoinRequests { + + @Test + @DisplayName("200 - 모임장이 가입 신청 목록을 정상적으로 조회한다") + void success_getJoinRequests() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("가입희망자", Gender.FEMALE, Level.B, 1010L)); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + partyJoinRequestRepository.save(joinRequest); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId()) + .param("status", "PENDING") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content[0].userId").value(applicant.getId())); + } + + @Test + @DisplayName("200 - 모임장이 가입 승인된 멤버 목록(APPROVED)을 정상적으로 조회한다") + void success_getJoinRequests_approved() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("승인된멤버", Gender.MALE, Level.C, 1015L)); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.APPROVED) + .build(); + partyJoinRequestRepository.save(joinRequest); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId()) + .param("status", "APPROVED") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content[0].userId").value(applicant.getId())); + } + + @Test + @DisplayName("403 - 모임장이 아닌 사용자가 조회하면 INSUFFICIENT_PERMISSION 예외가 반환된다") + void fail_getJoinRequests_notOwner() throws Exception { + // given + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId()) + .param("status", "PENDING")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("400 - 잘못된 상태값을 전달하면 INVALID_REQUEST_STATUS 예외가 반환된다") + void fail_getJoinRequests_invalidStatus() throws Exception { + // given + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(get("/api/parties/{partyId}/join-requests", party.getId()) + .param("status", "UNKNOWN_STATUS")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_REQUEST_STATUS.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티의 가입 신청 목록 조회 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_getJoinRequests_partyNotFound() throws Exception { + mockMvc.perform(get("/api/parties/{partyId}/join-requests", 9999L) + .param("status", "PENDING")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("GET /api/parties/{partyId}/members/suggestions - 신규 멤버 추천받기") + class GetRecommendedMembers { + + @Test + @DisplayName("200 - 추천 조건(지역/나이/급수)에 맞는 멤버가 추천 목록에 포함된다") + void success_getRecommendedMembers() throws Exception { + // given + // party의 추천 조건: addr1=서울특별시, minBirthYear=1990, maxBirthYear=2005 + // party에 남성 A급 레벨 추가 + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + // 추천 조건을 모두 만족하는 멤버: 남성, A급, 생년 1995, 서울특별시 주소(isMain=true) + Member suggestedMember = memberRepository.save( + MemberFixture.createMember("추천회원", Gender.MALE, Level.A, 1080L, LocalDate.of(1995, 6, 1)) + ); + memberAddrRepository.save(MemberAddr.builder() + .member(suggestedMember) + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("테헤란로") + .latitude(37.5) + .longitude(127.0) + .isMain(true) + .build()); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(get("/api/parties/{partyId}/members/suggestions", party.getId()) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content[0].userId").value(suggestedMember.getId())); + } + + @Test + @DisplayName("404 - 존재하지 않는 모임의 추천 멤버를 조회하면 PARTY_NOT_FOUND 예외 발생") + void fail_getRecommendedMembers_partyNotFound() throws Exception { + // given + Long invalidPartyId = 9999L; + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(get("/api/parties/{partyId}/members/suggestions", invalidPartyId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("POST /api/parties/{partyId}/invitations - 신규 멤버 초대 보내기") + class CreateInvitation { + + @Test + @DisplayName("200 - 모임장이 새로운 멤버를 초대하고 invitationId를 반환한다") + void success_createInvitation() throws Exception { + // given + Member newMember = memberRepository.save(MemberFixture.createMember("새멤버", Gender.FEMALE, Level.B, 1090L)); + PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(newMember.getId()); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON201")) + .andExpect(jsonPath("$.data.invitationId").exists()); + } + + @Test + @DisplayName("403 - 모임장이 아닌 사용자가 초대하면 INSUFFICIENT_PERMISSION 발생") + void fail_createInvitation_notOwner() throws Exception { + // given + Member newMember = memberRepository.save(MemberFixture.createMember("새멤버", Gender.FEMALE, Level.B, 1091L)); + PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(newMember.getId()); + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("409 - 이미 모임 멤버인 사람을 초대하면 ALREADY_MEMBER 발생") + void fail_createInvitation_alreadyMember() throws Exception { + // given - normalMember는 setUp()에서 이미 모임 멤버로 추가된 상태 + PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(normalMember.getId()); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.ALREADY_MEMBER.getCode())); + } + + @Test + @DisplayName("409 - 이미 대기 중인 초대가 있는 멤버를 중복 초대하면 INVITATION_ALREADY_EXISTS 발생") + void fail_createInvitation_duplicateInvitation() throws Exception { + // given + Member newMember = memberRepository.save(MemberFixture.createMember("새멤버", Gender.FEMALE, Level.B, 1092L)); + partyInvitationRepository.save(PartyInvitation.create(party, manager, newMember)); + + PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(newMember.getId()); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVITATION_ALREADY_EXISTS.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티에서 멤버 초대 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_createInvitation_partyNotFound() throws Exception { + PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(normalMember.getId()); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/invitations", 9999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 회원을 모임에 초대하면 MEMBER_NOT_FOUND 에러를 반환한다") + void fail_createInvitation_memberNotFound() throws Exception { + PartyInviteCreateDTO.Request request = new PartyInviteCreateDTO.Request(9999L); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/invitations", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("PATCH /api/parties/invitations/{invitationId} - 모임 초대 처리") + class ActionInvitation { + + @Test + @DisplayName("200 - 초대받은 멤버가 승인하면 모임 멤버로 추가된다") + void success_actionInvitation_approve() throws Exception { + // given + Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1100L)); + + PartyInvitation invitation = partyInvitationRepository.save( + PartyInvitation.create(party, manager, invitee) + ); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE); + SecurityContextHelper.setAuthentication(invitee.getId(), invitee.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + PartyInvitation updated = partyInvitationRepository.findById(invitation.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(RequestStatus.APPROVED); + assertThat(memberPartyRepository.existsByPartyAndMember(party, invitee)).isTrue(); + } + + @Test + @DisplayName("200 - 초대받은 멤버가 거절하면 상태가 REJECTED로 바뀌고 멤버로 추가되지 않는다") + void success_actionInvitation_reject() throws Exception { + // given + Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1101L)); + + PartyInvitation invitation = partyInvitationRepository.save( + PartyInvitation.create(party, manager, invitee) + ); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT); + SecurityContextHelper.setAuthentication(invitee.getId(), invitee.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + PartyInvitation updated = partyInvitationRepository.findById(invitation.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(RequestStatus.REJECTED); + assertThat(memberPartyRepository.existsByPartyAndMember(party, invitee)).isFalse(); + } + + @Test + @DisplayName("403 - 초대받은 사람이 아닌 제3자가 처리하면 NOT_YOUR_INVITATION 발생") + void fail_actionInvitation_notYourInvitation() throws Exception { + // given + Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1102L)); + + PartyInvitation invitation = partyInvitationRepository.save( + PartyInvitation.create(party, manager, invitee) + ); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE); + // normalMember는 초대받은 사람이 아님 + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.NOT_YOUR_INVITATION.getCode())); + } + + @Test + @DisplayName("409 - 이미 처리된 초대를 다시 처리하면 INVITATION_ALREADY_ACTIONS 발생") + void fail_actionInvitation_alreadyActions() throws Exception { + // given + Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1103L)); + + // 이미 APPROVED 처리된 초대 + PartyInvitation invitation = partyInvitationRepository.save( + PartyInvitation.create(party, manager, invitee) + ); + invitation.updateStatus(RequestStatus.APPROVED); + partyInvitationRepository.save(invitation); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT); + SecurityContextHelper.setAuthentication(invitee.getId(), invitee.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVITATION_ALREADY_ACTIONS.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 회원이 초대를 처리하려 할 때 MEMBER_NOT_FOUND 에러를 반환한다") + void fail_actionInvitation_memberNotFound() throws Exception { + // given + Member invitee = memberRepository.save(MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 1104L)); + PartyInvitation invitation = partyInvitationRepository.save( + PartyInvitation.create(party, manager, invitee) + ); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE); + // 인증 정보에는 유효하지만 DB에는 없는 ID 설정 + SecurityContextHelper.setAuthentication(9999L, "ghost"); + + // when & then + mockMvc.perform(patch("/api/parties/invitations/{invitationId}", invitation.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("PATCH /api/parties/{partyId}/join-requests/{requestId} - 모임 가입 신청 처리") + class ActionJoinRequest { + + @Test + @DisplayName("200 - 모임장이 가입 신청을 성공적으로 승인한다") + void success_actionJoinRequest_approve() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1020L)); + + PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build()); + + PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(actionRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + PartyJoinRequest updatedRequest = partyJoinRequestRepository.findById(joinRequest.getId()).orElseThrow(); + assertThat(updatedRequest.getStatus()).isEqualTo(RequestStatus.APPROVED); + boolean isMember = memberPartyRepository.existsByPartyAndMember(party, applicant); + assertThat(isMember).isTrue(); + } + + @Test + @DisplayName("200 - 모임장이 가입 신청을 성공적으로 거절한다") + void success_actionJoinRequest_reject() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("탈락자", Gender.FEMALE, Level.B, 1030L)); + + PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build()); + + PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.REJECT); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(actionRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + PartyJoinRequest updatedRequest = partyJoinRequestRepository.findById(joinRequest.getId()).orElseThrow(); + assertThat(updatedRequest.getStatus()).isEqualTo(RequestStatus.REJECTED); + boolean isMember = memberPartyRepository.existsByPartyAndMember(party, applicant); + assertThat(isMember).isFalse(); + } + + @Test + @DisplayName("403 - 모임장이 아닌 사용자가 가입 신청을 처리하려 하면 INSUFFICIENT_PERMISSION 발생") + void fail_actionJoinRequest_notOwner() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1040L)); + + PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build()); + + PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(actionRequest))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("409 - 이미 처리된 가입 신청을 다시 처리하려 할 때 JOIN_REQUEST_ALREADY_ACTIONS 상태 반환") + void fail_actionJoinRequest_alreadyHandled() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1050L)); + + PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.APPROVED) + .build()); + + PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", party.getId(), joinRequest.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(actionRequest))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.JOIN_REQUEST_ALREADY_ACTIONS.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티의 가입 신청 요청 처리 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_actionJoinRequest_partyNotFound() throws Exception { + // given + Member applicant = memberRepository.save(MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 1060L)); + PartyJoinRequest joinRequest = partyJoinRequestRepository.save(PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build()); + + PartyJoinActionDTO.Request actionRequest = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/join-requests/{requestId}", 9999L, joinRequest.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(actionRequest))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("POST /api/parties - 모임 생성") + class CreateParty { + + @Test + @DisplayName("200 - 모임을 성공적으로 생성하고 DB 저장 상태를 확인한다") + void success_createParty() throws Exception { + // given + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("새로운 통합 모임") + .partyType("혼복") + .minBirthYear(1990) + .maxBirthYear(2000) + .activityTime("오전") + .addr1("서울특별시") + .addr2("강남구") + .activityDay(List.of("월", "수")) + .price(10000) + .joinPrice(5000) + .designatedCock("통합테스트콕") + .maleLevel(List.of("A조")) + .femaleLevel(List.of("B조")) + .build(); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON201")) + .andExpect(jsonPath("$.data.partyId").exists()); + + // 검증 + List parties = partyRepository.findAll(); + Party createdParty = parties.stream() + .filter(p -> p.getPartyName().equals("새로운 통합 모임")) + .findFirst() + .orElseThrow(); + + assertThat(createdParty.getOwnerId()).isEqualTo(manager.getId()); + assertThat(createdParty.getDesignatedCock()).isEqualTo("통합테스트콕"); + } + + @Test + @DisplayName("400 - 본인의 나이가 모임 조건에 맞지 않을 때 에러를 반환한다") + void fail_createParty_invalidAgeRange() throws Exception { + // given + // manager는 1995년생. 모임 조건을 2000~2010으로 설정. + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("청년 모임") + .partyType("혼복") + .minBirthYear(2000) + .maxBirthYear(2010) + .activityTime("오후") + .activityDay(List.of("금")) + .addr1("서울특별시") + .addr2("강남구") + .price(10000) + .joinPrice(0) + .femaleLevel(List.of("A조")) + .maleLevel(List.of("A조")) + .designatedCock("청년콕") + .build(); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())) - .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_IS_DELETED.getMessage())); + .andExpect(jsonPath("$.code").value(PartyErrorCode.AGE_NOT_MATCH.getCode())); + } + + @Test + @DisplayName("400 - 혼복 모임에서 남자 급수 정보가 누락되었을 때 에러를 반환한다") + void fail_createParty_missingMaleLevelInMixDoubles() throws Exception { + // given + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("혼복 모임") + .partyType("혼복") + .minBirthYear(1990) + .maxBirthYear(2005) + .activityTime("오전") + .activityDay(List.of("토")) + .addr1("서울특별시") + .addr2("강남구") + .price(10000) + .joinPrice(0) + .designatedCock("혼복콕") + .maleLevel(null) + .femaleLevel(List.of("A조")) + .build(); + + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties") + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.MALE_LEVEL_REQUIRED.getCode())); + } + } + + @Nested + @DisplayName("PATCH /api/parties/{partyId}/members/{memberId}/role - 멤버 역할(부모임장) 설정") + class UpdateMemberRole { + + @Test + @DisplayName("200 - 모임장이 일반 멤버를 부모임장으로 성공적으로 임명한다") + void success_updateMemberRole() throws Exception { + // given + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), normalMember.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 + MemberParty targetMemberParty = memberPartyRepository.findByPartyAndMember(party, normalMember).orElseThrow(); + assertThat(targetMemberParty.getRole()).isEqualTo(Role.PARTY_SUBMANAGER); + } + + @Test + @DisplayName("403 - 모임장이 아닌 멤버가 역할 수정을 시도하면 INSUFFICIENT_PERMISSION 예외를 반환한다") + void fail_updateMemberRole_notOwner() throws Exception { + // given + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + // 일반 멤버가 권한 변경 시도 + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), normalMember.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("403 - 대상자가 모임장인 경우 권한 변경은 실패하며 CANNOT_ASSIGN_TO_OWNER 예외를 반환한다") + void fail_updateMemberRole_targetIsOwner() throws Exception { + // given + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_MEMBER); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), manager.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티의 멤버 역할 수정 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_updateMemberRole_partyNotFound() throws Exception { + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", 9999L, normalMember.getId()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 멤버의 역할 수정 시 MEMBER_NOT_FOUND 에러를 반환한다") + void fail_updateMemberRole_memberNotFound() throws Exception { + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(patch("/api/parties/{partyId}/members/{memberId}/role", party.getId(), 9999L) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(MemberErrorCode.MEMBER_NOT_FOUND.getCode())); + } + } + + @Nested + @DisplayName("POST /api/parties/{partyId}/keywords - 키워드 추가") + class AddKeyword { + + @Test + @DisplayName("200 - 모임장이 유효한 키워드를 정상적으로 추가한다") + void success_addKeyword() throws Exception { + // given + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request( + List.of("친목", "가입비 무료") + ); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/keywords", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")); + + // 검증 - DB에 키워드가 실제로 저장됐는지 확인 + Party updatedParty = partyRepository.findById(party.getId()).orElseThrow(); + assertThat(updatedParty.getKeywords()).hasSize(2); + } + + @Test + @DisplayName("403 - 모임장이 아닌 사용자가 키워드를 추가하면 INSUFFICIENT_PERMISSION 발생") + void fail_addKeyword_notOwner() throws Exception { + // given + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("친목")); + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/keywords", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INSUFFICIENT_PERMISSION.getCode())); + } + + @Test + @DisplayName("400 - 유효하지 않은 키워드 문자열을 전달하면 INVALID_KEYWORD 발생") + void fail_addKeyword_invalidKeyword() throws Exception { + // given + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("존재하지않는키워드")); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + // when & then + mockMvc.perform(post("/api/parties/{partyId}/keywords", party.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.INVALID_KEYWORD.getCode())); + } + + @Test + @DisplayName("404 - 존재하지 않는 파티에 키워드 추가 시 PARTY_NOT_FOUND 에러를 반환한다") + void fail_addKeyword_partyNotFound() throws Exception { + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("새키워드")); + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/keywords", 9999L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())); } } } diff --git a/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java new file mode 100644 index 000000000..e90511e4c --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java @@ -0,0 +1,1852 @@ +package umc.cockple.demo.domain.party.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.service.ChatRoomService; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.service.NotificationCommandService; +import umc.cockple.demo.domain.party.converter.PartyConverter; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.domain.PartyInvitation; +import umc.cockple.demo.domain.party.domain.PartyJoinRequest; +import umc.cockple.demo.domain.party.dto.*; +import umc.cockple.demo.domain.party.enums.ParticipationType; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.enums.RequestAction; +import umc.cockple.demo.domain.party.enums.RequestStatus; +import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyInvitationRepository; +import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PartyCommandServiceTest { + + @InjectMocks + private PartyCommandServiceImpl partyCommandService; + + @Mock + private PartyRepository partyRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private NotificationCommandService notificationCommandService; + @Mock + private PartyAddrRepository partyAddrRepository; + @Mock + private MemberPartyRepository memberPartyRepository; + @Mock + private ChatRoomService chatRoomService; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @Mock + private PartyJoinRequestRepository partyJoinRequestRepository; + @Mock + private PartyInvitationRepository partyInvitationRepository; + @Mock + private FileService fileService; + + private PartyConverter partyConverter; + + @BeforeEach + void setUp() { + partyConverter = new PartyConverter(fileService); + ReflectionTestUtils.setField(partyCommandService, "partyConverter", partyConverter); + } + + @Nested + @DisplayName("leaveParty") + class LeaveParty { + + @Test + @DisplayName("성공 - 일반 멤버가 모임을 탈퇴한다") + void success_leaveParty() { + // given + Long partyId = 1L; + Long memberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L); + ReflectionTestUtils.setField(owner, "id", 1L); + Party party = PartyFixture.createParty("탈퇴 테스트 모임", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member member = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, 10L); + ReflectionTestUtils.setField(member, "id", memberId); + + MemberParty memberParty = MemberFixture.createMemberParty(party, member, Role.PARTY_MEMBER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberPartyRepository.findByPartyAndMember(party, member)).willReturn(Optional.of(memberParty)); + + // when + partyCommandService.leaveParty(partyId, memberId); + + // then + verify(memberPartyRepository).delete(memberParty); + verify(chatRoomService).leavePartyChatRoom(partyId, memberId); + verify(applicationEventPublisher).publishEvent(any(PartyMemberJoinedEvent.class)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 모임인 경우 PARTY_NOT_FOUND 예외가 발생한다") + void fail_leaveParty_partyNotFound() { + // given + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.leaveParty(999L, 1L)) + .isInstanceOf(PartyException.class) + .satisfies( + e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 삭제된 모임인 경우 PARTY_IS_DELETED 예외가 발생한다") + void fail_leaveParty_partyDeleted() { + // given + Long partyId = 1L; + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("삭제된 모임", 1L, addr); + party.delete(); + + Member member = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, 1L); + ReflectionTestUtils.setField(member, "id", 1L); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, 1L)) + .isInstanceOf(PartyException.class) + .satisfies( + e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + } + + @Test + @DisplayName("실패 - 모임장이 탈퇴하려 할 경우 INVALID_ACTION_FOR_OWNER 예외가 발생한다") + void fail_leaveParty_isOwner() { + // given + Long partyId = 1L; + Long ownerId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L); + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("탈퇴 테스트 모임", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + + // when & then + assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, ownerId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.INVALID_ACTION_FOR_OWNER)); + } + + @Test + @DisplayName("실패 - 부모임장이 탈퇴하려 할 경우 INVALID_ACTION_FOR_SUBOWNER 예외가 발생한다") + void fail_leaveParty_isSubOwner() { + // given + Long partyId = 1L; + Long subManagerId = 2L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L); + ReflectionTestUtils.setField(owner, "id", 1L); + Party party = PartyFixture.createParty("탈퇴 테스트 모임", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member subManager = MemberFixture.createMember("부모임장", Gender.MALE, Level.A, 2L); + ReflectionTestUtils.setField(subManager, "id", subManagerId); + + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(subManagerId)).willReturn(Optional.of(subManager)); + given(memberPartyRepository.findByPartyIdAndRole(partyId, Role.PARTY_SUBMANAGER)) + .willReturn(Optional.of(subManagerParty)); + + // when & then + assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, subManagerId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.INVALID_ACTION_FOR_SUBOWNER)); + } + + @Test + @DisplayName("실패 - 모임 멤버가 아닌 경우 NOT_MEMBER 예외가 발생한다") + void fail_leaveParty_notMember() { + // given + Long partyId = 1L; + Long memberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("탈퇴 테스트 모임", 1L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + Member member = MemberFixture.createMember("외부인", Gender.MALE, Level.A, 10L); + ReflectionTestUtils.setField(member, "id", memberId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberPartyRepository.findByPartyAndMember(party, member)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, memberId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.NOT_MEMBER)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외가 발생한다") + void fail_leaveParty_memberNotFound() { + // given + Long partyId = 1L; + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", 1L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(10L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.leaveParty(partyId, 10L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("createJoinRequest") + class CreateJoinRequest { + + @Test + @DisplayName("성공 - 사용자가 특정 모임에 가입 신청을 성공적으로 완료한다") + void success_createJoinRequest() { + // given + Long partyId = 1L; + Long memberId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("가입 신청 모임", 10L, addr); + Member member = MemberFixture.createMember("지원자", Gender.MALE, Level.B, 1L, LocalDate.of(1995, 1, 1)); + ReflectionTestUtils.setField(member, "id", memberId); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false); + given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(false); + given(partyJoinRequestRepository.save(any(PartyJoinRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + PartyJoinCreateDTO.Response response = partyCommandService.createJoinRequest(partyId, memberId); + + // then + assertThat(response).isNotNull(); + verify(partyJoinRequestRepository).save(any(PartyJoinRequest.class)); + } + + @Test + @DisplayName("실패 - 이미 해당 모임의 멤버인 경우 ALREADY_MEMBER 예외가 발생한다") + void fail_createJoinRequest_alreadyMember() { + // given + Long partyId = 1L; + Long memberId = 1L; + + Party party = PartyFixture.createParty("가입 신청 모임", 10L, null); + Member member = MemberFixture.createMember("지원자", Gender.MALE, Level.B, 1L); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(true); + + // when & then + assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.ALREADY_MEMBER)); + } + + @Test + @DisplayName("실패 - 대기 중인 가입 신청이 이미 존재하는 경우 JOIN_REQUEST_ALREADY_EXISTS 예외가 발생한다") + void fail_createJoinRequest_alreadyRequested() { + // given + Long partyId = 1L; + Long memberId = 1L; + + Party party = PartyFixture.createParty("가입 신청 모임", 10L, null); + Member member = MemberFixture.createMember("지원자", Gender.MALE, Level.B, 1L); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false); + given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(true); + + // when & then + assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_ALREADY_EXISTS)); + } + + @Test + @DisplayName("실패 - 모임 유형에 맞지 않는 성별인 경우 GENDER_NOT_MATCH 예외가 발생한다") + void fail_createJoinRequest_genderMismatch() { + // given + Long partyId = 1L; + Long memberId = 1L; + + // 여복 모임 생성 + Party party = Party.builder() + .partyName("여복 모임") + .partyType(ParticipationType.WOMEN_DOUBLES) + .status(PartyStatus.ACTIVE) + .ownerId(10L) + .build(); + Member member = MemberFixture.createMember("남자지원자", Gender.MALE, Level.B, 1L); // 남성 지원 + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false); + given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(false); + + // when & then + assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.GENDER_NOT_MATCH)); + } + + @Test + @DisplayName("실패 - 모임의 나이 조건에 맞지 않는 경우 AGE_NOT_MATCH 예외가 발생한다") + void fail_createJoinRequest_ageMismatch() { + // given + Long partyId = 1L; + Long memberId = 1L; + + // 1990~2000년생 모임 + Party party = Party.builder() + .partyName("나이 제한 모임") + .minBirthYear(1990) + .maxBirthYear(2000) + .status(PartyStatus.ACTIVE) + .ownerId(10L) + .build(); + // 1980년생 지원자 (범위 밖) + Member member = MemberFixture.createMember("나이많은지원자", Gender.MALE, Level.B, 1L, LocalDate.of(1980, 1, 1)); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberPartyRepository.existsByPartyAndMember(party, member)).willReturn(false); + given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, RequestStatus.PENDING)).willReturn(false); + + // when & then + assertThatThrownBy(() -> partyCommandService.createJoinRequest(partyId, memberId)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.AGE_NOT_MATCH)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_createJoinRequest_partyNotFound() { + // given + Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.B, 1L); + ReflectionTestUtils.setField(member, "id", 1L); + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.createJoinRequest(999L, 1L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_createJoinRequest_memberNotFound() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.createJoinRequest(1L, 1L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("createParty - 모임 생성") + class CreateParty { + + @Test + @DisplayName("성공 - 올바른 데이터 입력 시 모임이 생성되고 채팅방이 개설된다") + void success_createParty() { + // given + Long memberId = 1L; + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("테스트 모임") + .partyType("혼복") + .minBirthYear(1990) + .maxBirthYear(2000) + .activityTime("오전") + .addr1("서울") + .addr2("강남") + .activityDay(List.of("월", "수")) + .price(10000) + .joinPrice(5000) + .designatedCock("테스트콕") + .maleLevel(List.of("A조")) + .femaleLevel(List.of("B조")) + .build(); + + Member owner = Member.builder() + .id(memberId) + .gender(Gender.MALE) + .level(Level.A) + .birth(LocalDate.of(1995, 1, 1)) + .build(); + + PartyAddr partyAddr = PartyAddr.builder().id(1L).build(); + Party savedParty = Party.builder().id(1L).partyName("테스트 모임").build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(owner)); + given(partyAddrRepository.findByAddr1AndAddr2(anyString(), anyString())).willReturn(Optional.of(partyAddr)); + given(partyRepository.save(any(Party.class))).willReturn(savedParty); + + // when + PartyCreateDTO.Response response = partyCommandService.createParty(memberId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.partyId()).isEqualTo(1L); + verify(partyRepository, times(1)).save(any(Party.class)); + verify(chatRoomService, times(1)).createPartyChatRoom(any(Party.class), eq(owner)); + } + + @Test + @DisplayName("실패 - 혼복 모임 생성 시 남자 급수 정보가 누락되면 MALE_LEVEL_REQUIRED 예외가 발생한다") + void fail_createParty_mixDoubles_maleLevelMissing() { + // given + Long memberId = 1L; + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("혼복 모임") + .partyType("혼복") + .minBirthYear(1990) + .maxBirthYear(2000) + .activityTime("오전") + .activityDay(List.of("월")) + .femaleLevel(List.of("A조")) + .maleLevel(null) // 누락 + .build(); + + Member owner = Member.builder() + .id(memberId) + .gender(Gender.MALE) + .birth(LocalDate.of(1995, 1, 1)) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(owner)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createParty(memberId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.MALE_LEVEL_REQUIRED); + } + + @Test + @DisplayName("실패 - 여복 모임 생성 시 남자 급수 정보가 포함되면 MALE_LEVEL_NOT_NEEDED 예외가 발생한다") + void fail_createParty_womenDoubles_maleLevelProvided() { + // given + Long memberId = 1L; + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("여복 모임") + .partyType("여복") + .minBirthYear(1990) + .maxBirthYear(2010) + .activityTime("오전") + .activityDay(List.of("토")) + .femaleLevel(List.of("A조")) + .maleLevel(List.of("A조")) // 포함됨 + .build(); + + Member owner = Member.builder() + .id(memberId) + .gender(Gender.FEMALE) + .birth(LocalDate.of(2000, 1, 1)) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(owner)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createParty(memberId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.MALE_LEVEL_NOT_NEEDED); + } + + @Test + @DisplayName("실패 - 모임 유형의 성별 조건과 생성자의 성별이 맞지 않으면 GENDER_NOT_MATCH 예외가 발생한다") + void fail_createParty_genderMismatch() { + // given + Long memberId = 1L; + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("여복 모임") + .partyType("여복") + .minBirthYear(1990) + .maxBirthYear(2010) + .activityTime("오전") + .activityDay(List.of("일")) + .femaleLevel(List.of("A조")) + .build(); + + Member maleOwner = Member.builder() + .id(memberId) + .gender(Gender.MALE) // 남성이 여복 모임 생성 시도 + .birth(LocalDate.of(2000, 1, 1)) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(maleOwner)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createParty(memberId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.GENDER_NOT_MATCH); + } + + @Test + @DisplayName("실패 - 생성자의 나이가 모임의 나이 제한 범위를 벗어나면 AGE_NOT_MATCH 예외가 발생한다") + void fail_createParty_ageMismatch() { + // given + Long memberId = 1L; + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("청년 모임") + .partyType("혼복") + .minBirthYear(2000) + .maxBirthYear(2010) + .maleLevel(List.of("A조")) + .femaleLevel(List.of("A조")) + .activityTime("오후") + .activityDay(List.of("금")) + .build(); + + Member oldOwner = Member.builder() + .id(memberId) + .gender(Gender.MALE) + .birth(LocalDate.of(1980, 1, 1)) // 80년생이 00~10년생 모임 생성 시도 + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(oldOwner)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createParty(memberId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.AGE_NOT_MATCH); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_createParty_memberNotFound() { + // given + Long memberId = 1L; + PartyCreateDTO.Request request = PartyCreateDTO.Request.builder() + .partyName("테스트 모임") + .partyType("혼복") + .activityTime("오전") + .addr1("서울") + .addr2("강남") + .activityDay(List.of("월", "수")) + .price(10000) + .joinPrice(5000) + .designatedCock("테스트콕") + .maleLevel(List.of("A조")) + .femaleLevel(List.of("B조")) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.createParty(memberId, request)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("updateParty") + class UpdateParty { + + @Test + @DisplayName("성공 - 모임장이 모임 정보를 정상적으로 수정한다") + void success_updateParty() { + // given + Long partyId = 1L; + Long memberId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L); + ReflectionTestUtils.setField(owner, "id", memberId); + + Party party = PartyFixture.createParty("기존 모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityDay(List.of("토", "일")) + .activityTime("오전") + .designatedCock("새 콕") + .joinPrice(0) + .price(10000) + .content("새로운 내용") + .build(); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(owner)); + + // when + partyCommandService.updateParty(partyId, memberId, request); + + // then + assertThat(party.getDesignatedCock()).isEqualTo("새 콕"); + assertThat(party.getActiveDays().size()).isEqualTo(2); // 토, 일 + assertThat(party.getJoinPrice()).isEqualTo(0); + assertThat(party.getPrice()).isEqualTo(10000); + assertThat(party.getContent()).isEqualTo("새로운 내용"); + + verify(notificationCommandService, times(1)).createNotification(any()); + } + + @Test + @DisplayName("실패 - 조회된 모임이 없는 경우 PARTY_NOT_FOUND 예외 발생") + void fail_updateParty_partyNotFound() { + // given + Long partyId = 99L; + Long memberId = 1L; + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder().build(); + + given(partyRepository.findById(partyId)).willReturn(Optional.empty()); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.updateParty(partyId, memberId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 모임장이 아닌 사용자가 수정을 시도할 경우 INSUFFICIENT_PERMISSION 예외 발생") + void fail_updateParty_insufficientPermission() { + // given + Long partyId = 1L; + Long memberId = 10L; // 일반 멤버 (ownerId=1 과 다름) + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L); + ReflectionTestUtils.setField(owner, "id", 1L); + + Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, 2L); + ReflectionTestUtils.setField(normalMember, "id", memberId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityTime("오전").build(); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(normalMember)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.updateParty(partyId, memberId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_updateParty_memberNotFound() { + // given + Long partyId = 1L; + Long memberId = 1L; + Party party = PartyFixture.createParty("모임명", 1L, null); + PartyUpdateDTO.Request request = PartyUpdateDTO.Request.builder() + .activityTime("오전") + .build(); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.updateParty(partyId, memberId, request)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("deleteParty") + class DeleteParty { + + @Test + @DisplayName("성공 - 모임장이 모임을 정상적으로 삭제(비활성화)한다") + void success_deleteParty() { + // given + Long partyId = 1L; + Long ownerId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Party party = PartyFixture.createParty("삭제할 모임", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + + // when + partyCommandService.deleteParty(partyId, ownerId); + + // then + assertThat(party.getStatus()).isEqualTo(PartyStatus.INACTIVE); + } + + @Test + @DisplayName("실패 - 모임장이 아닌 멤버가 모임 삭제를 시도할 경우 INSUFFICIENT_PERMISSION 발생") + void fail_deleteParty_notOwner() { + // given + Long partyId = 1L; + Long ownerId = 1L; + Long notOwnerId = 2L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Member notOwner = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, notOwnerId); + ReflectionTestUtils.setField(notOwner, "id", notOwnerId); + + Party party = PartyFixture.createParty("삭제할 모임", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(notOwnerId)).willReturn(Optional.of(notOwner)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.deleteParty(partyId, notOwnerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 이미 삭제된 모임을 삭제하려고 시도할 경우 PARTY_IS_DELETED 발생") + void fail_deleteParty_partyDeleted() { + // given + Long partyId = 1L; + Long ownerId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Party party = PartyFixture.createParty("이미 삭제된 모임", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + party.delete(); // 상태를 INACTIVE로 변경 + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.deleteParty(partyId, ownerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_IS_DELETED); + } + + @Test + @DisplayName("실패 - 조회된 모임이 존재하지 않을 경우 PARTY_NOT_FOUND 발생") + void fail_deleteParty_partyNotFound() { + // given + Long invalidId = 999L; + given(partyRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.deleteParty(invalidId, 1L)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_deleteParty_memberNotFound() { + // given + Long partyId = 1L; + Long memberId = 1L; + Party party = PartyFixture.createParty("모임명", 1L, null); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.deleteParty(partyId, memberId)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("updateMemberRole") + class UpdateMemberRole { + + @Test + @DisplayName("성공 - 모임장이 일반 멤버를 부모임장으로 지정하면 기존 부모임장은 일반 멤버로 강등되고 새 부모임장이 지정된다") + void success_updateMemberRole() { + // given + Long partyId = 1L; + Long currentOwnerId = 1L; + Long targetMemberId = 10L; + Long oldSubManagerId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, currentOwnerId); + ReflectionTestUtils.setField(owner, "id", currentOwnerId); + + Member targetMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, targetMemberId); + ReflectionTestUtils.setField(targetMember, "id", targetMemberId); + + Member oldSubManager = MemberFixture.createMember("기존부모임장", Gender.MALE, Level.A, oldSubManagerId); + ReflectionTestUtils.setField(oldSubManager, "id", oldSubManagerId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER); + MemberParty oldSubManagerParty = MemberFixture.createMemberParty(party, oldSubManager, Role.PARTY_SUBMANAGER); + + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember)); + given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty)); + given(memberPartyRepository.findByPartyIdAndRole(partyId, Role.PARTY_SUBMANAGER)).willReturn(Optional.of(oldSubManagerParty)); + given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(List.of(targetMemberParty, oldSubManagerParty)); + + // when + partyCommandService.updateMemberRole(partyId, targetMemberId, currentOwnerId, request); + + // then + assertThat(targetMemberParty.getRole()).isEqualTo(Role.PARTY_SUBMANAGER); + assertThat(oldSubManagerParty.getRole()).isEqualTo(Role.PARTY_MEMBER); + verify(notificationCommandService, times(4)).createNotification(any()); + } + + @Test + @DisplayName("실패 - 이미 요청한 역할과 같은 역할인 경우 변경 없이 반환된다") + void fail_updateMemberRole_sameRole() { + // given + Long partyId = 1L; + Long ownerId = 1L; + Long targetId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetId); + ReflectionTestUtils.setField(targetMember, "id", targetId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty targetMemberParty = spy(MemberFixture.createMemberParty(party, targetMember, Role.PARTY_SUBMANAGER)); + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(targetId)).willReturn(Optional.of(targetMember)); + given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty)); + + // when + partyCommandService.updateMemberRole(partyId, targetId, ownerId, request); + + // then + verify(targetMemberParty, never()).changeRole(any()); + verify(notificationCommandService, never()).createNotification(any()); + } + + @Test + @DisplayName("실패 - 대상 멤버가 이미 모임장인 경우 권한을 변경하려 하면 CANNOT_ASSIGN_TO_OWNER 발생") + void fail_updateMemberRole_targetIsOwner() { + // given + Long partyId = 1L; + Long ownerId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + // 타겟이 이미 모임장 권한을 가짐 + MemberParty memberParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER); + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(memberParty)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.updateMemberRole(partyId, ownerId, ownerId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.CANNOT_ASSIGN_TO_OWNER); + } + + @Test + @DisplayName("실패 - 현재 사용자가 모임장이 아닐 경우 INSUFFICIENT_PERMISSION 발생") + void fail_updateMemberRole_notOwner() { + // given + Long partyId = 1L; + Long ownerId = 1L; + Long notOwnerId = 2L; + Long targetId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Member notOwner = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, notOwnerId); + ReflectionTestUtils.setField(notOwner, "id", notOwnerId); + + Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetId); + ReflectionTestUtils.setField(targetMember, "id", targetId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER); + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(targetId)).willReturn(Optional.of(targetMember)); + given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty)); + + // when & then (notOwnerId를 currentMemberId로 전달하여 실행) + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.updateMemberRole(partyId, targetId, notOwnerId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_updateMemberRole_partyNotFound() { + // given + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.updateMemberRole(999L, 1L, 1L, request)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_updateMemberRole_memberNotFound() { + // given + Long partyId = 1L; + Party party = PartyFixture.createParty("모임명", 1L, null); + PartyMemberRoleDTO.Request request = new PartyMemberRoleDTO.Request(Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.updateMemberRole(partyId, 1L, 1L, request)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("removeMember") + class RemoveMember { + + @Test + @DisplayName("성공 - 모임장이 일반 멤버를 성공적으로 강퇴한다") + void success_removeMember() { + // given + Long partyId = 1L; + Long ownerId = 1L; + Long targetMemberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetMemberId); + ReflectionTestUtils.setField(targetMember, "id", targetMemberId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER); + MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember)); + given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(ownerParty)); + given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty)); + + // when + partyCommandService.removeMember(partyId, targetMemberId, ownerId); + + // then + verify(memberPartyRepository, times(1)).delete(targetMemberParty); + verify(chatRoomService, times(1)).leavePartyChatRoom(partyId, targetMemberId); + } + + @Test + @DisplayName("성공 - 부모임장이 일반 멤버를 성공적으로 강퇴한다") + void success_removeMember_bySubManager() { + // given + Long partyId = 1L; + Long subManagerId = 2L; + Long targetMemberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member subManager = MemberFixture.createMember("부모임장", Gender.MALE, Level.A, subManagerId); + ReflectionTestUtils.setField(subManager, "id", subManagerId); + Member targetMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.A, targetMemberId); + ReflectionTestUtils.setField(targetMember, "id", targetMemberId); + + Party party = PartyFixture.createParty("모임명", 1L, addr); // ownerId = 1L + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); + MemberParty targetMemberParty = MemberFixture.createMemberParty(party, targetMember, Role.PARTY_MEMBER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(subManagerId)).willReturn(Optional.of(subManager)); + given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember)); + given(memberPartyRepository.findByPartyAndMember(party, subManager)).willReturn(Optional.of(subManagerParty)); + given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.of(targetMemberParty)); + + // when + partyCommandService.removeMember(partyId, targetMemberId, subManagerId); + + // then + verify(memberPartyRepository, times(1)).delete(targetMemberParty); + verify(chatRoomService, times(1)).leavePartyChatRoom(partyId, targetMemberId); + } + + @Test + @DisplayName("실패 - 권한이 없는 멤버가 타인을 강퇴하려 하면 INSUFFICIENT_PERMISSION 발생") + void fail_removeMember_insufficientPermission() { + // given + Long partyId = 1L; + Long subManagerId = 2L; + Long targetOwnerId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, targetOwnerId); + ReflectionTestUtils.setField(owner, "id", targetOwnerId); + Member subManager = MemberFixture.createMember("부모임장", Gender.MALE, Level.A, subManagerId); + ReflectionTestUtils.setField(subManager, "id", subManagerId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER); + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(subManagerId)).willReturn(Optional.of(subManager)); + given(memberRepository.findById(targetOwnerId)).willReturn(Optional.of(owner)); + given(memberPartyRepository.findByPartyAndMember(party, subManager)).willReturn(Optional.of(subManagerParty)); + given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(ownerParty)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.removeMember(partyId, targetOwnerId, subManagerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 모임장이 자신을 강퇴하려 할 경우 CANNOT_REMOVE_SELF 발생") + void fail_removeMember_cannotRemoveSelf() { + // given + Long partyId = 1L; + Long ownerId = 1L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberPartyRepository.findByPartyAndMember(party, owner)).willReturn(Optional.of(ownerParty)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.removeMember(partyId, ownerId, ownerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.CANNOT_REMOVE_SELF); + } + + @Test + @DisplayName("실패 - 대상 멤버가 모임 소속이 아닐 경우 NOT_MEMBER 발생") + void fail_removeMember_notMember() { + // given + Long partyId = 1L; + Long ownerId = 1L; + Long targetMemberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member targetMember = MemberFixture.createMember("타겟", Gender.MALE, Level.A, targetMemberId); + ReflectionTestUtils.setField(targetMember, "id", targetMemberId); + + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberRepository.findById(targetMemberId)).willReturn(Optional.of(targetMember)); + + // 타겟 멤버가 모임 소속이 아님 -> findMemberPartyOrThrow 에서 NOT_MEMBER 발생 + given(memberPartyRepository.findByPartyAndMember(party, targetMember)).willReturn(Optional.empty()); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.removeMember(partyId, targetMemberId, ownerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.NOT_MEMBER); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_removeMember_partyNotFound() { + // given + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.removeMember(999L, 1L, 1L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_removeMember_memberNotFound() { + // given + Long partyId = 1L; + Party party = PartyFixture.createParty("모임명", 1L, null); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.removeMember(partyId, 10L, 1L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("actionJoinRequest") + class ActionJoinRequest { + + @Test + @DisplayName("성공 - 모임장이 가입 신청을 승인하고 알림과 채팅방 진입, 이벤트를 발생시킨다") + void success_actionJoinRequest_approve() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long requestId = 100L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", requestId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest)); + given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(false); + + // when + partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId); + + // then + assertThat(joinRequest.getStatus()).isEqualTo(RequestStatus.APPROVED); + verify(chatRoomService).joinPartyChatRoom(partyId, applicant); + verify(applicationEventPublisher).publishEvent(any(PartyMemberJoinedEvent.class)); + verify(notificationCommandService).createNotification(any()); + } + + @Test + @DisplayName("성공 - 모임장이 가입 신청을 거절하면 상태만 REJECTED로 바뀌고 다른 사이드이펙트가 발생하지 않는다") + void success_actionJoinRequest_reject() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long requestId = 100L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", requestId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.REJECT); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest)); + given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(false); + + // when + partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId); + + // then + assertThat(joinRequest.getStatus()).isEqualTo(RequestStatus.REJECTED); + verifyNoInteractions(chatRoomService); + verifyNoInteractions(applicationEventPublisher); + verifyNoInteractions(notificationCommandService); + } + + @Test + @DisplayName("실패 - 대상자가 이미 모임 멤버인 경우 ALREADY_MEMBER 검증 에러가 발생한다") + void fail_actionJoinRequest_alreadyMember() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long requestId = 100L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", requestId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest)); + given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(true); // 이미 멤버 + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.ALREADY_MEMBER); + } + + @Test + @DisplayName("실패 - 이미 처리된 가입 신청을 다시 처리하려 할 때 JOIN_REQUEST_ALREADY_ACTIONS 발생") + void fail_actionJoinRequest_alreadyActions() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long requestId = 100L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.APPROVED) // 이미 승인됨 + .build(); + ReflectionTestUtils.setField(joinRequest, "id", requestId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.REJECT); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest)); + given(memberPartyRepository.existsByPartyAndMember(party, applicant)).willReturn(false); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_ALREADY_ACTIONS); + } + + @Test + @DisplayName("실패 - 해당 가입 요청을 찾을 수 없는 경우 JOIN_REQUEST_NOT_FOUND 발생") + void fail_actionJoinRequest_notFound() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long requestId = 999L; // 존재하지 않는 ID + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.empty()); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 모임장이 아닌 사용자가 가입 신청을 처리하려 할 때 INSUFFICIENT_PERMISSION 발생") + void fail_actionJoinRequest_insufficientPermission() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long notOwnerId = 99L; + Long requestId = 100L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); // 실제 모임장 + ReflectionTestUtils.setField(owner, "id", ownerId); + Party party = PartyFixture.createParty("모임명", owner.getId(), addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", requestId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionJoinRequest(partyId, notOwnerId, requestDTO, requestId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 처리하려는 가입 신청이 해당 모임의 것이 아닌 경우 JOIN_REQUEST_PARTY_NOT_FOUND 발생") + void fail_actionJoinRequest_joinRequestPartyNotFound() { + // given + Long partyId = 1L; + Long wrongPartyId = 2L; + Long ownerId = 10L; + Long requestId = 100L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Party targetParty = PartyFixture.createParty("대상 모임", owner.getId(), addr); + ReflectionTestUtils.setField(targetParty, "id", partyId); + + Party wrongParty = PartyFixture.createParty("다른 모임", owner.getId(), addr); + ReflectionTestUtils.setField(wrongParty, "id", wrongPartyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + // 다른 모임으로 가입신청 + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(wrongParty) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", requestId); + + PartyJoinActionDTO.Request requestDTO = new PartyJoinActionDTO.Request(RequestAction.APPROVE); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(targetParty)); + given(partyJoinRequestRepository.findById(requestId)).willReturn(Optional.of(joinRequest)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionJoinRequest(partyId, ownerId, requestDTO, requestId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.JOIN_REQUEST_PARTY_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_actionJoinRequest_partyNotFound() { + // given + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.actionJoinRequest(999L, 1L, new PartyJoinActionDTO.Request(RequestAction.APPROVE), 1L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + } + + @Nested + @DisplayName("createInvitation") + class CreateInvitation { + + @Test + @DisplayName("성공 - 모임장이 새로운 멤버를 초대하고 invitationId를 반환한다") + void success_createInvitation() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false); + given(partyInvitationRepository.existsByPartyAndInviteeAndStatus(party, invitee, RequestStatus.PENDING)).willReturn(false); + + PartyInvitation savedInvitation = PartyInvitation.create(party, owner, invitee); + ReflectionTestUtils.setField(savedInvitation, "id", 100L); + given(partyInvitationRepository.save(any())).willReturn(savedInvitation); + + // when + PartyInviteCreateDTO.Response response = partyCommandService.createInvitation(partyId, inviteeId, ownerId); + + // then + assertThat(response.invitationId()).isEqualTo(100L); + verify(notificationCommandService).createNotification(any()); + } + + @Test + @DisplayName("실패 - 모임장이 아닌 사용자가 초대하려 하면 INSUFFICIENT_PERMISSION 발생") + void fail_createInvitation_notOwner() { + // given + Long partyId = 1L; + Long nonOwnerId = 99L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member nonOwner = MemberFixture.createMember("일반멤버", Gender.MALE, Level.B, nonOwnerId); + ReflectionTestUtils.setField(nonOwner, "id", nonOwnerId); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", 10L, addr); // ownerId = 10L + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(nonOwnerId)).willReturn(Optional.of(nonOwner)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createInvitation(partyId, inviteeId, nonOwnerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 이미 모임에 가입한 멤버를 초대하면 ALREADY_MEMBER 발생") + void fail_createInvitation_alreadyMember() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("대상멤버", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(true); // 이미 멤버 + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createInvitation(partyId, inviteeId, ownerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.ALREADY_MEMBER); + } + + @Test + @DisplayName("실패 - 이미 대기 중인 초대가 있는 멤버를 중복 초대하면 INVITATION_ALREADY_EXISTS 발생") + void fail_createInvitation_duplicateInvitation() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("대상멤버", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(ownerId)).willReturn(Optional.of(owner)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false); + given(partyInvitationRepository.existsByPartyAndInviteeAndStatus(party, invitee, RequestStatus.PENDING)).willReturn(true); // 이미 대기중 초대 + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.createInvitation(partyId, inviteeId, ownerId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INVITATION_ALREADY_EXISTS); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_createInvitation_partyNotFound() { + // given + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.createInvitation(999L, 1L, 1L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_createInvitation_memberNotFound() { + // given + Long partyId = 1L; + Party party = PartyFixture.createParty("모임명", 1L, null); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.createInvitation(partyId, 1L, 1L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("actionInvitation") + class ActionInvitation { + + @Test + @DisplayName("성공 - 초대받은 멤버가 승인하면 모임 멤버로 추가되고 알림이 발생한다") + void success_actionInvitation_approve() { + // given + Long invitationId = 100L; + Long ownerId = 10L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", 1L); + + PartyInvitation invitation = PartyInvitation.create(party, owner, invitee); + ReflectionTestUtils.setField(invitation, "id", invitationId); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE); + + given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false); + + // when + partyCommandService.actionInvitation(inviteeId, request, invitationId); + + // then + assertThat(invitation.getStatus()).isEqualTo(RequestStatus.APPROVED); + verify(chatRoomService).joinPartyChatRoom(party.getId(), invitee); + verify(applicationEventPublisher).publishEvent(any(PartyMemberJoinedEvent.class)); + verify(notificationCommandService).createNotification(any()); + } + + @Test + @DisplayName("성공 - 초대받은 멤버가 거절하면 상태만 REJECTED로 바뀌고 사이드이펙트가 없다") + void success_actionInvitation_reject() { + // given + Long invitationId = 100L; + Long ownerId = 10L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", 1L); + + PartyInvitation invitation = PartyInvitation.create(party, owner, invitee); + ReflectionTestUtils.setField(invitation, "id", invitationId); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT); + + given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false); + + // when + partyCommandService.actionInvitation(inviteeId, request, invitationId); + + // then + assertThat(invitation.getStatus()).isEqualTo(RequestStatus.REJECTED); + verifyNoInteractions(chatRoomService); + verifyNoInteractions(applicationEventPublisher); + verifyNoInteractions(notificationCommandService); + } + + @Test + @DisplayName("실패 - 초대받은 사람이 아닌 제3자가 처리하려 하면 NOT_YOUR_INVITATION 발생") + void fail_actionInvitation_notYourInvitation() { + // given + Long invitationId = 100L; + Long ownerId = 10L; + Long inviteeId = 20L; + Long otherId = 99L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Member other = MemberFixture.createMember("제3자", Gender.MALE, Level.C, otherId); + ReflectionTestUtils.setField(other, "id", otherId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", 1L); + + PartyInvitation invitation = PartyInvitation.create(party, owner, invitee); + ReflectionTestUtils.setField(invitation, "id", invitationId); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE); + + given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + given(memberRepository.findById(otherId)).willReturn(Optional.of(other)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionInvitation(otherId, request, invitationId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.NOT_YOUR_INVITATION); + } + + @Test + @DisplayName("실패 - 이미 처리된 초대를 다시 처리하려 할 때 INVITATION_ALREADY_ACTIONS 발생") + void fail_actionInvitation_alreadyActions() { + // given + Long invitationId = 100L; + Long ownerId = 10L; + Long inviteeId = 20L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, inviteeId); + ReflectionTestUtils.setField(invitee, "id", inviteeId); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", 1L); + + // 이미 승인된 초대 + PartyInvitation invitation = PartyInvitation.create(party, owner, invitee); + ReflectionTestUtils.setField(invitation, "id", invitationId); + invitation.updateStatus(RequestStatus.APPROVED); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.REJECT); + + given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + given(memberRepository.findById(inviteeId)).willReturn(Optional.of(invitee)); + given(memberPartyRepository.existsByPartyAndMember(party, invitee)).willReturn(false); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.actionInvitation(inviteeId, request, invitationId)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INVITATION_ALREADY_ACTIONS); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_actionInvitation_memberNotFound() { + // given + Long invitationId = 100L; + Party party = PartyFixture.createParty("모임명", 1L, null); + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1L); + Member invitee = MemberFixture.createMember("초대대상", Gender.FEMALE, Level.B, 20L); + PartyInvitation invitation = PartyInvitation.create(party, owner, invitee); + + PartyInviteActionDTO.Request request = new PartyInviteActionDTO.Request(RequestAction.APPROVE); + given(partyInvitationRepository.findById(invitationId)).willReturn(Optional.of(invitation)); + given(memberRepository.findById(20L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.actionInvitation(20L, request, invitationId)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("addKeyword") + class AddKeyword { + + @Test + @DisplayName("성공 - 모임장이 유효한 키워드 목록을 모임에 추가한다") + void success_addKeyword() { + // given + Long partyId = 1L; + Long ownerId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request( + List.of("친목", "가입비 무료") + ); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + // when + partyCommandService.addKeyword(partyId, ownerId, request); + + // then + assertThat(party.getKeywords()).hasSize(2); + } + + @Test + @DisplayName("실패 - 모임장이 아닌 사용자가 키워드를 추가하면 INSUFFICIENT_PERMISSION 발생") + void fail_addKeyword_notOwner() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Long nonOwnerId = 99L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("친목")); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.addKeyword(partyId, nonOwnerId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION); + } + + @Test + @DisplayName("실패 - 유효하지 않은 키워드 문자열을 전달하면 INVALID_KEYWORD 발생") + void fail_addKeyword_invalidKeyword() { + // given + Long partyId = 1L; + Long ownerId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("존재하지않는키워드")); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyCommandService.addKeyword(partyId, ownerId, request)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.INVALID_KEYWORD); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_addKeyword_partyNotFound() { + // given + PartyKeywordDTO.Request request = new PartyKeywordDTO.Request(List.of("새키워드")); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyCommandService.addKeyword(999L, 10L, request)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java index 5f6a9005e..e9427a595 100644 --- a/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/service/PartyQueryServiceTest.java @@ -1,5 +1,6 @@ package umc.cockple.demo.domain.party.service; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -7,17 +8,32 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.file.service.FileService; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.domain.party.converter.PartyConverter; import umc.cockple.demo.domain.party.domain.Party; import umc.cockple.demo.domain.party.domain.PartyAddr; -import umc.cockple.demo.domain.party.dto.PartyMemberDTO; +import umc.cockple.demo.domain.party.domain.PartyJoinRequest; +import umc.cockple.demo.domain.party.dto.*; +import umc.cockple.demo.domain.party.enums.RequestStatus; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyJoinRequestRepository; import umc.cockple.demo.domain.party.repository.PartyRepository; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; @@ -27,13 +43,13 @@ import java.time.LocalDate; import java.util.List; -import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -46,19 +62,78 @@ class PartyQueryServiceTest { @Mock private PartyRepository partyRepository; @Mock + private MemberRepository memberRepository; + private PartyConverter partyConverter; + @Mock private MemberPartyRepository memberPartyRepository; @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock + private ExerciseRepository exerciseRepository; + @Mock + private PartyBookmarkRepository partyBookmarkRepository; + @Mock + private MemberAddrRepository memberAddrRepository; + @Mock + private FileService fileService; + @Mock + private PartyJoinRequestRepository partyJoinRequestRepository; + + @BeforeEach + void setUp() { + partyConverter = new PartyConverter(fileService); + ReflectionTestUtils.setField(partyQueryService, "partyConverter", partyConverter); + } @Nested @DisplayName("getPartyMembers") class GetPartyMembers { @Test - @DisplayName("멤버 목록과 마지막 운동일을 함께 반환한다") - void success() { + @DisplayName("성공 - 모임의 멤버들을 역할별로 성공적으로 조회한다.") + void success_getPartyMembers() { + // given + Long partyId = 1L; + Long currentMemberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + Party party = PartyFixture.createParty("테스트 모임", 10L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member manager = MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L); + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L); + Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L); + + ReflectionTestUtils.setField(manager, "id", 10L); + ReflectionTestUtils.setField(subManager, "id", 20L); + ReflectionTestUtils.setField(normalMember, "id", 30L); + + MemberParty mp1 = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER); + MemberParty mp2 = MemberFixture.createMemberParty(party, subManager, Role.PARTY_SUBMANAGER); + MemberParty mp3 = MemberFixture.createMemberParty(party, normalMember, Role.PARTY_MEMBER); + List memberParties = List.of(mp1, mp2, mp3); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(memberParties); + given(memberExerciseRepository.findLastExerciseDateByMemberIdsAndPartyId(anyList(), + eq(partyId))) + .willReturn(List.of()); + + // when + PartyMemberDTO.Response result = partyQueryService.getPartyMembers(partyId, currentMemberId); + + // then + assertThat(result.members()).hasSize(3); + assertThat(result.summary().totalCount()).isEqualTo(3); + assertThat(result.summary().maleCount()).isEqualTo(2); + assertThat(result.summary().femaleCount()).isEqualTo(1); + } + + @Test + @DisplayName("성공 - 멤버 목록과 마지막 운동일을 함께 반환한다") + void success_getPartyMembers_withExerciseHistory() { // given Long partyId = 1L; Long currentMemberId = 10L; @@ -71,8 +146,8 @@ void success() { ReflectionTestUtils.setField(manager, "id", 10L); ReflectionTestUtils.setField(member1, "id", 20L); - MemberParty mp1 = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER); - MemberParty mp2 = MemberFixture.createMemberParty(party, member1, Role.party_MEMBER); + MemberParty mp1 = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER); + MemberParty mp2 = MemberFixture.createMemberParty(party, member1, Role.PARTY_MEMBER); List memberParties = List.of(mp1, mp2); LocalDate lastDate = LocalDate.of(2025, 1, 10); @@ -88,26 +163,23 @@ void success() { given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(memberParties); given(memberExerciseRepository.findLastExerciseDateByMemberIdsAndPartyId( List.of(10L, 20L), partyId)).willReturn(rawResult); - given(partyConverter.toPartyMemberDTO(eq(memberParties), eq(currentMemberId), any())) - .willReturn(expected); // when PartyMemberDTO.Response result = partyQueryService.getPartyMembers(partyId, currentMemberId); // then - assertThat(result).isEqualTo(expected); - verify(memberExerciseRepository).findLastExerciseDateByMemberIdsAndPartyId( - List.of(10L, 20L), partyId); - verify(partyConverter).toPartyMemberDTO( - eq(memberParties), - eq(currentMemberId), - eq(Map.of(20L, lastDate)) - ); + assertThat(result.summary().totalCount()).isEqualTo(2); + assertThat(result.members()).hasSize(2); + // 마지막 운동일 확인 (멤버1 id: 20L) + assertThat(result.members().stream() + .filter(m -> m.memberId().equals(20L)) + .findFirst() + .get().lastExerciseDate()).isEqualTo(lastDate); } @Test - @DisplayName("운동 기록이 없는 멤버는 빈 Map이 converter에 전달된다") - void noExerciseHistory() { + @DisplayName("성공 - 운동 기록이 없는 멤버는 빈 Map이 converter에 전달된다") + void success_getPartyMembers_noExerciseHistory() { // given Long partyId = 1L; Long currentMemberId = 10L; @@ -117,41 +189,38 @@ void noExerciseHistory() { ReflectionTestUtils.setField(party, "id", partyId); Member manager = MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L); ReflectionTestUtils.setField(manager, "id", 10L); - MemberParty mp = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER); + MemberParty mp = MemberFixture.createMemberParty(party, manager, Role.PARTY_MANAGER); List memberParties = List.of(mp); given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); given(memberPartyRepository.findAllByPartyIdWithMember(partyId)).willReturn(memberParties); given(memberExerciseRepository.findLastExerciseDateByMemberIdsAndPartyId( List.of(10L), partyId)).willReturn(List.of()); - given(partyConverter.toPartyMemberDTO(any(), any(), any())).willReturn(null); // when - partyQueryService.getPartyMembers(partyId, currentMemberId); + PartyMemberDTO.Response result = partyQueryService.getPartyMembers(partyId, currentMemberId); // then - verify(partyConverter).toPartyMemberDTO( - eq(memberParties), - eq(currentMemberId), - eq(Map.of()) - ); + assertThat(result.members()).hasSize(1); + assertThat(result.members().get(0).lastExerciseDate()).isNull(); } @Test - @DisplayName("존재하지 않는 파티면 PartyException을 던진다") - void partyNotFound() { + @DisplayName("실패 - 존재하지 않는 파티면 PartyException을 던진다") + void fail_getPartyMembers_partyNotFound() { // given given(partyRepository.findById(99L)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> partyQueryService.getPartyMembers(99L, 1L)) .isInstanceOf(PartyException.class) - .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); } @Test - @DisplayName("비활성화된 파티면 PartyException을 던진다") - void partyInactive() { + @DisplayName("실패 - 비활성화된 파티면 PartyException을 던진다") + void fail_getPartyMembers_partyInactive() { // given PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); Party inactiveParty = PartyFixture.createParty("테스트 모임", 10L, addr); @@ -163,8 +232,763 @@ void partyInactive() { // when & then assertThatThrownBy(() -> partyQueryService.getPartyMembers(1L, 1L)) .isInstanceOf(PartyException.class) - .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + } + } + + @Nested + @DisplayName("getMyParties") + class GetMyParties { + + @Test + @DisplayName("성공 - 내 모임 목록을 최신순(기본값)으로 페이징하여 반환한다") + void success_getMyParties() { + // given + Long memberId = 10L; + Pageable pageable = PageRequest.of(0, 10); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + Party party1 = PartyFixture.createParty("모임1", 10L, addr); + ReflectionTestUtils.setField(party1, "id", 1L); + Party party2 = PartyFixture.createParty("모임2", 10L, addr); + ReflectionTestUtils.setField(party2, "id", 2L); + Party party3 = PartyFixture.createParty("모임3", 10L, addr); + ReflectionTestUtils.setField(party3, "id", 3L); + + Slice partySlice = new SliceImpl<>(List.of(party3, party2, party1), pageable, false); + + given(partyRepository.findMyParty(eq(memberId), eq(false), any(Pageable.class))) + .willReturn(partySlice); + given(exerciseRepository.findTotalExerciseCountsByPartyIds(anyList())) + .willReturn(List.of()); + given(exerciseRepository.findUpcomingExercisesByPartyIds(anyList())) + .willReturn(List.of()); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)) + .willReturn(Set.of(1L, 2L, 3L)); + + // when + Slice result = partyQueryService.getMyParties(memberId, false, "최신순", pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(3L); + assertThat(result.getContent().get(1).partyId()).isEqualTo(2L); + assertThat(result.getContent().get(2).partyId()).isEqualTo(1L); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + verify(partyRepository).findMyParty(eq(memberId), eq(false), captor.capture()); + assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection()) + .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC); + } + + @Test + @DisplayName("성공 - 내 모임 목록을 오래된 순으로 페이징하여 반환한다") + void success_getMyParties_oldest() { + // given + Long memberId = 10L; + Pageable pageable = PageRequest.of(0, 10); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + + Party party1 = PartyFixture.createParty("모임1", 10L, addr); + ReflectionTestUtils.setField(party1, "id", 1L); + Party party2 = PartyFixture.createParty("모임2", 10L, addr); + ReflectionTestUtils.setField(party2, "id", 2L); + Party party3 = PartyFixture.createParty("모임3", 10L, addr); + ReflectionTestUtils.setField(party3, "id", 3L); + + // 오래된 순 응답 가정 + Slice partySlice = new SliceImpl<>(List.of(party1, party2, party3), pageable, false); + + given(partyRepository.findMyParty(eq(memberId), eq(false), any(Pageable.class))) + .willReturn(partySlice); + given(exerciseRepository.findTotalExerciseCountsByPartyIds(anyList())) + .willReturn(List.of()); + given(exerciseRepository.findUpcomingExercisesByPartyIds(anyList())) + .willReturn(List.of()); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)) + .willReturn(Set.of()); + + // when + Slice result = partyQueryService.getMyParties(memberId, false, "오래된 순", pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(1L); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + verify(partyRepository).findMyParty(eq(memberId), eq(false), captor.capture()); + assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection()) + .isEqualTo(org.springframework.data.domain.Sort.Direction.ASC); + } + + @Test + @DisplayName("성공 - 내 모임 목록을 운동 많은 순으로 페이징하여 반환한다") + void success_getMyParties_exerciseCount() { + // given + Long memberId = 10L; + Pageable pageable = PageRequest.of(0, 10); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + + Party party1 = PartyFixture.createParty("모임1", 10L, addr); + ReflectionTestUtils.setField(party1, "id", 1L); + Party party2 = PartyFixture.createParty("모임2", 10L, addr); + ReflectionTestUtils.setField(party2, "id", 2L); + Party party3 = PartyFixture.createParty("모임3", 10L, addr); + ReflectionTestUtils.setField(party3, "id", 3L); + + // 운동 많은 순 응답 가정 (20회, 10회, 5회) + Slice partySlice = new SliceImpl<>(List.of(party2, party1, party3), pageable, false); + + given(partyRepository.findMyParty(eq(memberId), eq(false), any(Pageable.class))) + .willReturn(partySlice); + given(exerciseRepository.findTotalExerciseCountsByPartyIds(anyList())) + .willReturn(List.of()); + given(exerciseRepository.findUpcomingExercisesByPartyIds(anyList())) + .willReturn(List.of()); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)) + .willReturn(Set.of()); + + // when + Slice result = partyQueryService.getMyParties(memberId, false, "운동 많은 순", pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(2L); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + verify(partyRepository).findMyParty(eq(memberId), eq(false), captor.capture()); + assertThat(captor.getValue().getSort().getOrderFor("exerciseCount").getDirection()) + .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC); + } + + @Test + @DisplayName("실패 - 유효하지 않은 정렬 기준을 전달하면 INVALID_ORDER_TYPE 발생") + void fail_getMyParties_invalidSort() { + // given + Long memberId = 10L; + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> partyQueryService.getMyParties(memberId, false, "존재하지않는정렬", pageable)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.INVALID_ORDER_TYPE)); + } + } + + @Nested + @DisplayName("getSimpleMyParties") + class GetSimpleMyParties { + + @Test + @DisplayName("성공 - 유효한 회원 ID가 주어지면 가입한 모임 3개의 간략화된 목록을 반환한다") + void success_getSimpleMyParties() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + + Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", memberId); + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + + Party party1 = PartyFixture.createParty("모임1", 10L, addr); + ReflectionTestUtils.setField(party1, "id", 1L); + Party party2 = PartyFixture.createParty("모임2", 10L, addr); + ReflectionTestUtils.setField(party2, "id", 2L); + Party party3 = PartyFixture.createParty("모임3", 10L, addr); + ReflectionTestUtils.setField(party3, "id", 3L); + + MemberParty mp1 = MemberFixture.createMemberParty(party1, member, Role.PARTY_MEMBER); + MemberParty mp2 = MemberFixture.createMemberParty(party2, member, Role.PARTY_MEMBER); + MemberParty mp3 = MemberFixture.createMemberParty(party3, member, Role.PARTY_MEMBER); + + Slice memberPartySlice = new SliceImpl<>(List.of(mp1, mp2, mp3), pageable, false); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberPartyRepository.findByMember(member, pageable)).willReturn(memberPartySlice); + + // when + Slice result = partyQueryService.getSimpleMyParties(memberId, pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(1L); + assertThat(result.getContent().get(1).partyId()).isEqualTo(2L); + assertThat(result.getContent().get(2).partyId()).isEqualTo(3L); + + verify(memberRepository).findById(memberId); + verify(memberPartyRepository).findByMember(member, pageable); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원일 경우 MemberException을 던진다") + void fail_getSimpleMyParties_memberNotFound() { + // given + Long invalidMemberId = 999L; + Pageable pageable = PageRequest.of(0, 10); + + given(memberRepository.findById(invalidMemberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyQueryService.getSimpleMyParties(invalidMemberId, pageable)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat( + ((MemberException) e) + .getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("getRecommendedParties") + class GetRecommendedParties { + + @Test + @DisplayName("성공 - Cockple 추천 모드 시 유저 정보(주소, 생년월일, 키워드)를 기반으로 추천 목록을 반환한다") + void success_getRecommendedParties_cockpleRecommend() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build(); + + Member member = MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L, + LocalDate.of(1995, 1, 1)); + ReflectionTestUtils.setField(member, "id", memberId); + + MemberAddr addr = MemberAddr.builder() + .member(member) + .addr1("서울특별시") + .isMain(true) + .build(); + + Party suggestedParty = PartyFixture.createParty("추천 모임", 2L, + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(suggestedParty, "id", 100L); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberAddrRepository.findByMemberAndIsMain(member, true)).willReturn(Optional.of(addr)); + given(partyRepository.findRecommendedParties(anyString(), anyInt(), any(), any(), anyLong())) + .willReturn(List.of(suggestedParty)); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of()); + + // when + Slice result = partyQueryService.getRecommendedParties(memberId, true, + filter, "최신순", pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).partyName()).isEqualTo("추천 모임"); + verify(partyRepository).findRecommendedParties(eq("서울특별시"), eq(1995), eq(Gender.MALE), + eq(Level.A), eq(memberId)); + } + + @Test + @DisplayName("성공 - 필터 모드 시 조건에 맞는 모임 목록을 최신순으로 반환한다") + void success_getRecommendedParties_latest() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().addr1("서울특별시").build(); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + Party p1 = PartyFixture.createParty("모임1", 2L, addr); + ReflectionTestUtils.setField(p1, "id", 1L); + Party p2 = PartyFixture.createParty("모임2", 2L, addr); + ReflectionTestUtils.setField(p2, "id", 2L); + Party p3 = PartyFixture.createParty("모임3", 2L, addr); + ReflectionTestUtils.setField(p3, "id", 3L); + + Slice partySlice = new SliceImpl<>(List.of(p3, p2, p1), pageable, false); + + given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class))) + .willReturn(partySlice); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of()); + + // when + Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "최신순", pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(3L); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + verify(partyRepository).searchParties(eq(memberId), eq(filter), captor.capture()); + assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection()) + .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC); + } + + @Test + @DisplayName("성공 - 필터 모드 시 조건에 맞는 모임 목록을 오래된 순으로 반환한다") + void success_getRecommendedParties_oldest() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build(); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + Party p1 = PartyFixture.createParty("모임1", 2L, addr); + ReflectionTestUtils.setField(p1, "id", 1L); + Party p2 = PartyFixture.createParty("모임2", 2L, addr); + ReflectionTestUtils.setField(p2, "id", 2L); + Party p3 = PartyFixture.createParty("모임3", 2L, addr); + ReflectionTestUtils.setField(p3, "id", 3L); + + Slice partySlice = new SliceImpl<>(List.of(p1, p2, p3), pageable, false); + + given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class))) + .willReturn(partySlice); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of()); + + // when + Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "오래된 순", pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(1L); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + verify(partyRepository).searchParties(eq(memberId), eq(filter), captor.capture()); + assertThat(captor.getValue().getSort().getOrderFor("createdAt").getDirection()) + .isEqualTo(org.springframework.data.domain.Sort.Direction.ASC); + } + + @Test + @DisplayName("성공 - 필터 모드 시 조건에 맞는 모임 목록을 운동 많은 순으로 반환한다") + void success_getRecommendedParties_exerciseCount() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build(); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + Party p1 = PartyFixture.createParty("모임1", 2L, addr); + ReflectionTestUtils.setField(p1, "id", 1L); + Party p2 = PartyFixture.createParty("모임2", 2L, addr); + ReflectionTestUtils.setField(p2, "id", 2L); + Party p3 = PartyFixture.createParty("모임3", 2L, addr); + ReflectionTestUtils.setField(p3, "id", 3L); + + Slice partySlice = new SliceImpl<>(List.of(p2, p1, p3), pageable, false); // 20회, 10회, 5회 가정 + + given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class))) + .willReturn(partySlice); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of()); + + // when + Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "운동 많은 순", pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).partyId()).isEqualTo(2L); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Pageable.class); + verify(partyRepository).searchParties(eq(memberId), eq(filter), captor.capture()); + assertThat(captor.getValue().getSort().getOrderFor("exerciseCount").getDirection()) + .isEqualTo(org.springframework.data.domain.Sort.Direction.DESC); + } + + @Test + @DisplayName("성공 - 검색 모드 시 검색 키워드에 맞는 모임 목록을 반환한다") + void success_getRecommendedParties_search() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().search("검색값").build(); + + PartyAddr addr = PartyFixture.createPartyAddr("서울특별시", "강남구"); + Party party = PartyFixture.createParty("검색결과모임", 2L, addr); + ReflectionTestUtils.setField(party, "id", 100L); + Slice partySlice = new SliceImpl<>(List.of(party), pageable, false); + + given(partyRepository.searchParties(eq(memberId), eq(filter), any(Pageable.class))) + .willReturn(partySlice); + given(partyBookmarkRepository.findAllPartyIdsByMemberId(memberId)).willReturn(Set.of()); + + // when + Slice result = partyQueryService.getRecommendedParties(memberId, false, filter, "최신순", pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).partyName()).isEqualTo("검색결과모임"); + verify(partyRepository).searchParties(eq(memberId), eq(filter), any(Pageable.class)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원 ID로 추천 요청 시 MEMBER_NOT_FOUND이 발생한다") + void fail_getRecommendedParties_memberNotFound() { + // given + Long memberId = 999L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyQueryService.getRecommendedParties(memberId, true, filter, "최신순", + pageable)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat( + ((MemberException) e) + .getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 대표 주소가 설정되지 않은 회원이 추천 요청 시 MAIN_ADDRESS_NULL이 발생한다") + void fail_getRecommendedParties_mainAddressNotFound() { + // given + Long memberId = 1L; + Pageable pageable = PageRequest.of(0, 10); + PartyFilterDTO.Request filter = PartyFilterDTO.Request.builder().build(); + + Member member = MemberFixture.createMember("매니저", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", memberId); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberAddrRepository.findByMemberAndIsMain(member, true)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyQueryService.getRecommendedParties(memberId, true, filter, "최신순", + pageable)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat( + ((MemberException) e) + .getCode()) + .isEqualTo(MemberErrorCode.MAIN_ADDRESS_NULL)); + } + } + + @Nested + @DisplayName("getPartyDetails") + class GetPartyDetails { + + @Test + @DisplayName("성공 - 모임 상세 정보를 정상적으로 조회한다 (비회원, 신청 전)") + void success_getPartyDetails_nonMember() { + // given + Long partyId = 1L; + Long memberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("상세 모임", 11L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.A, 1000L); + ReflectionTestUtils.setField(member, "id", memberId); + + PartyDetailDTO.Response expected = PartyDetailDTO.Response.builder() + .partyId(partyId) + .partyName("상세 모임") + .memberStatus("NOT_MEMBER") + .hasPendingJoinRequest(false) + .build(); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberPartyRepository.findByPartyAndMember(party, member)).willReturn(Optional.empty()); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false); + given(partyJoinRequestRepository.existsByPartyAndMemberAndStatus(party, member, + RequestStatus.PENDING)).willReturn(false); + + // when + PartyDetailDTO.Response result = partyQueryService.getPartyDetails(partyId, memberId); + + // then + assertThat(result.partyId()).isEqualTo(partyId); + assertThat(result.partyName()).isEqualTo("상세 모임"); + assertThat(result.memberStatus()).isEqualTo("NOT_MEMBER"); + assertThat(result.hasPendingJoinRequest()).isFalse(); + assertThat(result.isBookmarked()).isFalse(); + verify(partyRepository).findById(partyId); + } + + @Test + @DisplayName("성공 - 모임원인 경우 memberStatus가 MEMBER로 반환된다") + void success_getPartyDetails_member() { + // given + Long partyId = 1L; + Long memberId = 10L; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("상세 모임", 11L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + Member member = MemberFixture.createMember("사용자", Gender.MALE, Level.A, 1000L); + ReflectionTestUtils.setField(member, "id", memberId); + + MemberParty memberParty = MemberFixture.createMemberParty(party, member, Role.PARTY_MEMBER); + PartyDetailDTO.Response expected = PartyDetailDTO.Response.builder() + .partyId(partyId) + .memberStatus("MEMBER") + .memberRole("PARTY_MEMBER") + .build(); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(memberPartyRepository.findByPartyAndMember(party, member)) + .willReturn(Optional.of(memberParty)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(true); + + // when + PartyDetailDTO.Response result = partyQueryService.getPartyDetails(partyId, memberId); + + // then + assertThat(result.memberStatus()).isEqualTo("MEMBER"); + assertThat(result.memberRole()).isEqualTo("PARTY_MEMBER"); + assertThat(result.hasPendingJoinRequest()).isNull(); // 멤버이므로 null 반환 + assertThat(result.isBookmarked()).isTrue(); + } + + @Test + @DisplayName("실패 - 존재하지 않는 모임 조회 시 PARTY_NOT_FOUND이 발생한다") + void fail_getPartyDetails_partyNotFound() { + // given + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyQueryService.getPartyDetails(999L, 1L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("실패 - 삭제된 모임 조회 시 PARTY_IS_DELETED이 발생한다") + void fail_getPartyDetails_partyDeleted() { + // given + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("삭제된 모임", 11L, addr); + party.delete(); + given(partyRepository.findById(1L)).willReturn(Optional.of(party)); + given(memberRepository.findById(1L)).willReturn( + Optional.of(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1L))); + + // when & then + assertThatThrownBy(() -> partyQueryService.getPartyDetails(1L, 1L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 회원인 경우 MEMBER_NOT_FOUND 예외 발생") + void fail_getPartyDetails_memberNotFound() { + // given + Long partyId = 1L; + Party party = PartyFixture.createParty("상세 모임", 11L, null); + ReflectionTestUtils.setField(party, "id", partyId); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyQueryService.getPartyDetails(partyId, 1L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("getJoinRequests") + class GetJoinRequests { + + @Test + @DisplayName("성공 - 모임장이 가입 신청 목록을 정상적으로 조회한다") + void success_getJoinRequests() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Pageable pageable = PageRequest.of(0, 10); + String status = "PENDING"; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.PENDING) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", 100L); + + Slice requestSlice = new SliceImpl<>(List.of(joinRequest), pageable, false); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + given(partyJoinRequestRepository.findByPartyAndStatus(party, RequestStatus.PENDING, pageable)) + .willReturn(requestSlice); + + // when + Slice result = partyQueryService.getJoinRequests(partyId, ownerId, status, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).joinRequestId()).isEqualTo(100L); + verify(partyJoinRequestRepository).findByPartyAndStatus(party, RequestStatus.PENDING, pageable); + } + + @Test + @DisplayName("성공 - 모임장이 가입 승인된 멤버 목록(APPROVED)을 정상적으로 조회한다") + void success_getJoinRequests_approved() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Pageable pageable = PageRequest.of(0, 10); + String status = "APPROVED"; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member applicant = MemberFixture.createMember("지원자", Gender.FEMALE, Level.B, 20L); + ReflectionTestUtils.setField(applicant, "id", 20L); + + PartyJoinRequest joinRequest = PartyJoinRequest.builder() + .party(party) + .member(applicant) + .status(RequestStatus.APPROVED) + .build(); + ReflectionTestUtils.setField(joinRequest, "id", 101L); + + Slice requestSlice = new SliceImpl<>(List.of(joinRequest), pageable, false); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + given(partyJoinRequestRepository.findByPartyAndStatus(party, RequestStatus.APPROVED, pageable)) + .willReturn(requestSlice); + + // when + Slice result = partyQueryService.getJoinRequests(partyId, ownerId, status, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).joinRequestId()).isEqualTo(101L); + verify(partyJoinRequestRepository).findByPartyAndStatus(party, RequestStatus.APPROVED, pageable); + } + + @Test + @DisplayName("실패 - 모임장이 아닌 사용자가 조회하면 INSUFFICIENT_PERMISSION 발생") + void fail_getJoinRequests_notOwner() { + // given + Long partyId = 1L; + Long nonOwnerId = 20L; + Pageable pageable = PageRequest.of(0, 10); + String status = "PENDING"; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", 10L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member nonOwner = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, nonOwnerId); + ReflectionTestUtils.setField(nonOwner, "id", nonOwnerId); + MemberParty nonOwnerParty = MemberFixture.createMemberParty(party, nonOwner, Role.PARTY_MEMBER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + + // when & then + assertThatThrownBy(() -> partyQueryService.getJoinRequests(partyId, nonOwnerId, status, pageable)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.INSUFFICIENT_PERMISSION)); + } + + @Test + @DisplayName("실패 - 잘못된 상태값을 입력하면 INVALID_REQUEST_STATUS 발생") + void fail_getJoinRequests_invalidStatus() { + // given + Long partyId = 1L; + Long ownerId = 10L; + Pageable pageable = PageRequest.of(0, 10); + String invalidStatus = "UNKNOWN"; + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", ownerId, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member owner = MemberFixture.createMember("모임장", Gender.MALE, Level.A, ownerId); + ReflectionTestUtils.setField(owner, "id", ownerId); + MemberParty ownerParty = MemberFixture.createMemberParty(party, owner, Role.PARTY_MANAGER); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + + + // when & then + assertThatThrownBy(() -> partyQueryService.getJoinRequests(partyId, ownerId, invalidStatus, pageable)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()).isEqualTo(PartyErrorCode.INVALID_REQUEST_STATUS)); + } + + @Test + @DisplayName("실패 - 존재하지 않는 파티인 경우 PARTY_NOT_FOUND 예외 발생") + void fail_getJoinRequests_partyNotFound() { + // given + Long invalidId = 999L; + given(partyRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> partyQueryService.getJoinRequests(invalidId, 1L, "PENDING", PageRequest.of(0, 10))) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); } } + @Nested + @DisplayName("getRecommendedMembers") + class GetRecommendedMembers { + + @Test + @DisplayName("성공 - 조건에 맞는 추천 멤버 목록을 정상적으로 조회한다") + void success_getRecommendedMembers() { + // given + Long partyId = 1L; + String levelSearch = "B"; + Pageable pageable = PageRequest.of(0, 10); + + PartyAddr addr = PartyFixture.createPartyAddr("서울", "강남"); + Party party = PartyFixture.createParty("모임명", 1L, addr); + ReflectionTestUtils.setField(party, "id", partyId); + + Member suggestedMember = MemberFixture.createMember("추천회원", Gender.MALE, Level.B, 20L); + ReflectionTestUtils.setField(suggestedMember, "id", 20L); + + Slice membersSlice = new SliceImpl<>(List.of(suggestedMember), pageable, false); + + given(partyRepository.findById(partyId)).willReturn(Optional.of(party)); + given(memberRepository.findRecommendedMembers(party, levelSearch, pageable)) + .willReturn(membersSlice); + + // when + Slice result = partyQueryService.getRecommendedMembers(partyId, levelSearch, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).userId()).isEqualTo(20L); + verify(memberRepository).findRecommendedMembers(party, levelSearch, pageable); + } + + @Test + @DisplayName("실패 - 존재하지 않는 모임의 추천 멤버를 조회하면 PARTY_NOT_FOUND 발생") + void fail_getRecommendedMembers_partyNotFound() { + // given + Long partyId = 999L; + String levelSearch = null; + Pageable pageable = PageRequest.of(0, 10); + + given(partyRepository.findById(partyId)).willReturn(Optional.empty()); + + // when & then + PartyException exception = assertThrows(PartyException.class, + () -> partyQueryService.getRecommendedMembers(partyId, levelSearch, pageable)); + assertThat(exception.getCode()).isEqualTo(PartyErrorCode.PARTY_NOT_FOUND); + } + } }