diff --git a/src/main/java/com/back/domain/chat/room/controller/RoomChatApiController.java b/src/main/java/com/back/domain/chat/room/controller/RoomChatApiController.java index 3367f366..a438e68b 100644 --- a/src/main/java/com/back/domain/chat/room/controller/RoomChatApiController.java +++ b/src/main/java/com/back/domain/chat/room/controller/RoomChatApiController.java @@ -1,15 +1,20 @@ package com.back.domain.chat.room.controller; +import com.back.domain.chat.room.dto.ChatClearRequest; +import com.back.domain.chat.room.dto.ChatClearResponse; +import com.back.domain.chat.room.dto.ChatClearedNotification; import com.back.domain.chat.room.dto.RoomChatPageResponse; import com.back.domain.chat.room.service.RoomChatService; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; 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.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -17,14 +22,15 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api") -@Tag(name = "RoomChat API", description = "스터디룸 채팅 메시지 조회 관련 API") +@RequestMapping("/api/rooms/{roomId}/messages") +@Tag(name = "RoomChat API", description = "스터디룸 채팅 메시지 관련 API") public class RoomChatApiController { private final RoomChatService roomChatService; + private final SimpMessagingTemplate messagingTemplate; // 방 채팅 메시지 조회 (페이징, 특정 시간 이전 메시지) - @GetMapping("/rooms/{roomId}/messages") + @GetMapping @Operation(summary = "스터디룸 채팅방 메시지 목록 조회", description = "특정 채팅방의 이전 메시지 기록을 페이징하여 조회합니다.") public ResponseEntity> getRoomChatMessages( @PathVariable Long roomId, @@ -40,4 +46,48 @@ public ResponseEntity> getRoomChatMessages( .body(RsData.success("채팅 기록 조회 성공", chatHistory)); } + // 방 채팅 메시지 일괄 삭제 (방장, 부방장 권한) + @DeleteMapping + @Operation( + summary = "스터디룸 채팅 일괄 삭제", + description = "방장 또는 부방장이 해당 방의 모든 채팅 메시지를 삭제합니다. 실행 후 실시간으로 모든 방 멤버에게 알림이 전송됩니다." + ) + public ResponseEntity> clearRoomMessages( + @PathVariable Long roomId, + @Valid @RequestBody ChatClearRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + // 삭제 전 메시지 수 조회 + int messageCount = roomChatService.getRoomChatCount(roomId); + + // 채팅 일괄 삭제 실행 + ChatClearedNotification.ClearedByDto clearedByInfo = + roomChatService.clearRoomChat(roomId, userDetails.getUserId()); + + // 응답 데이터 생성 + ChatClearResponse responseData = ChatClearResponse.create( + roomId, + messageCount, + clearedByInfo.userId(), + clearedByInfo.nickname(), + clearedByInfo.role() + ); + + // WebSocket을 통해 실시간 알림 전송 + ChatClearedNotification notification = ChatClearedNotification.create( + roomId, + messageCount, + clearedByInfo.userId(), + clearedByInfo.nickname(), + clearedByInfo.profileImageUrl(), + clearedByInfo.role() + ); + + messagingTemplate.convertAndSend("/topic/room/" + roomId + "/chat-cleared", notification); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("채팅 메시지 일괄 삭제 완료", responseData)); + } + } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java b/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java index 1d8432fd..4501e5e3 100644 --- a/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java @@ -1,14 +1,19 @@ package com.back.domain.chat.room.controller; +import com.back.domain.chat.room.dto.ChatClearRequest; +import com.back.domain.chat.room.dto.ChatClearedNotification; import com.back.domain.studyroom.entity.RoomChatMessage; import com.back.domain.chat.room.dto.RoomChatMessageDto; +import com.back.global.exception.CustomException; import com.back.global.security.user.CustomUserDetails; -import com.back.global.websocket.dto.WebSocketErrorResponse; import com.back.domain.chat.room.service.RoomChatService; +import com.back.global.websocket.util.WebSocketErrorHelper; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.security.core.Authentication; @@ -16,6 +21,7 @@ import java.security.Principal; +@Slf4j @Controller @RequiredArgsConstructor @Tag(name = "RoomChat WebSocket", description = "STOMP를 이용한 실시간 채팅 WebSocket 컨트롤러 (Swagger에서 직접 테스트 불가)") @@ -23,15 +29,11 @@ public class RoomChatWebSocketController { private final RoomChatService roomChatService; private final SimpMessagingTemplate messagingTemplate; + private final WebSocketErrorHelper errorHelper; /** * 방 채팅 메시지 처리 * 클라이언트가 /app/chat/room/{roomId}로 메시지 전송 시 호출 - * - * @param roomId 스터디룸 ID - * @param chatMessage 채팅 메시지 (content, messageType, attachmentId) - * @param headerAccessor WebSocket 헤더 정보 - * @param principal 인증된 사용자 정보 */ @MessageMapping("/chat/room/{roomId}") public void handleRoomChat(@DestinationVariable Long roomId, @@ -43,7 +45,7 @@ public void handleRoomChat(@DestinationVariable Long roomId, // WebSocket에서 인증된 사용자 정보 추출 CustomUserDetails userDetails = extractUserDetails(principal); if (userDetails == null) { - sendErrorToUser(headerAccessor.getSessionId(), "WS_UNAUTHORIZED", "인증이 필요합니다"); + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); return; } @@ -75,16 +77,74 @@ public void handleRoomChat(@DestinationVariable Long roomId, // 해당 방의 모든 구독자에게 브로드캐스트 messagingTemplate.convertAndSend("/topic/room/" + roomId, responseMessage); + } catch (CustomException e) { + log.warn("채팅 메시지 처리 실패 - roomId: {}, error: {}", roomId, e.getMessage()); + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); + } catch (Exception e) { - // 에러 응답을 해당 사용자에게만 전송 - WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create( - "WS_ROOM_NOT_FOUND", - "존재하지 않는 방입니다" + log.error("채팅 메시지 처리 중 예상치 못한 오류 발생 - roomId: {}", roomId, e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "메시지 전송 중 오류가 발생했습니다"); + } + } + + /** + * 스터디룸 채팅 일괄 삭제 처리 + * 클라이언트가 /app/chat/room/{roomId}/clear로 삭제 요청 시 호출 + */ + @MessageMapping("/chat/room/{roomId}/clear") + public void clearRoomChat(@DestinationVariable Long roomId, + @Payload ChatClearRequest request, + SimpMessageHeaderAccessor headerAccessor, + Principal principal) { + + try { + log.info("WebSocket 채팅 일괄 삭제 요청 - roomId: {}", roomId); + + // 사용자 인증 확인 + CustomUserDetails userDetails = extractUserDetails(principal); + if (userDetails == null) { + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); + return; + } + + // 삭제 확인 메시지 검증 + if (!request.isValidConfirmMessage()) { + errorHelper.sendErrorToUser(headerAccessor.getSessionId(), "WS_011", + "삭제 확인 메시지가 일치하지 않습니다"); + return; + } + + Long currentUserId = userDetails.getUserId(); + + // 삭제 전에 메시지 수 먼저 조회 (삭제 후에는 0이 되므로) + int deletedCount = roomChatService.getRoomChatCount(roomId); + + // 채팅 일괄 삭제 실행 + ChatClearedNotification.ClearedByDto clearedByInfo = roomChatService.clearRoomChat(roomId, currentUserId); + + // 알림 생성 + ChatClearedNotification notification = ChatClearedNotification.create( + roomId, + deletedCount, // 삭제 전에 조회한 수 사용 + clearedByInfo.userId(), + clearedByInfo.nickname(), + clearedByInfo.profileImageUrl(), + clearedByInfo.role() ); - // 에러를 발생시킨 사용자에게만 전송 - String sessionId = headerAccessor.getSessionId(); - messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse); + // 해당 방의 모든 구독자에게 브로드캐스트 + messagingTemplate.convertAndSend("/topic/room/" + roomId + "/chat-cleared", notification); + + log.info("WebSocket 채팅 일괄 삭제 완료 - roomId: {}, deletedCount: {}, userId: {}", + roomId, deletedCount, currentUserId); + + } catch (CustomException e) { + log.warn("채팅 삭제 실패 - roomId: {}, error: {}", roomId, e.getMessage()); + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); + + } catch (Exception e) { + log.error("채팅 일괄 삭제 중 예상치 못한 오류 발생 - roomId: {}", roomId, e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "채팅 삭제 중 오류가 발생했습니다"); } } @@ -99,10 +159,4 @@ private CustomUserDetails extractUserDetails(Principal principal) { return null; } - // 특정 사용자에게 에러 메시지 전송 - private void sendErrorToUser(String sessionId, String errorCode, String errorMessage) { - WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create(errorCode, errorMessage); - messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse); - } - } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chat/room/dto/ChatClearRequest.java b/src/main/java/com/back/domain/chat/room/dto/ChatClearRequest.java new file mode 100644 index 00000000..3bb0b697 --- /dev/null +++ b/src/main/java/com/back/domain/chat/room/dto/ChatClearRequest.java @@ -0,0 +1,17 @@ +package com.back.domain.chat.room.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ChatClearRequest( + @NotBlank(message = "삭제 확인 메시지는 필수입니다") + String confirmMessage +) { + + // 확인 메시지 상수 + public static final String REQUIRED_CONFIRM_MESSAGE = "모든 채팅을 삭제하겠습니다"; + + // 확인 메시지인지 검증 + public boolean isValidConfirmMessage() { + return REQUIRED_CONFIRM_MESSAGE.equals(confirmMessage); + } +} diff --git a/src/main/java/com/back/domain/chat/room/dto/ChatClearResponse.java b/src/main/java/com/back/domain/chat/room/dto/ChatClearResponse.java new file mode 100644 index 00000000..075f9153 --- /dev/null +++ b/src/main/java/com/back/domain/chat/room/dto/ChatClearResponse.java @@ -0,0 +1,26 @@ +package com.back.domain.chat.room.dto; + +public record ChatClearResponse( + Long roomId, + Integer deletedCount, + java.time.LocalDateTime clearedAt, + ClearedByDto clearedBy +) { + + public record ClearedByDto( + Long userId, + String nickname, + String role + ) {} + + // 성공 응답 생성 헬퍼 + public static ChatClearResponse create(Long roomId, int deletedCount, + Long userId, String nickname, String role) { + return new ChatClearResponse( + roomId, + deletedCount, + java.time.LocalDateTime.now(), + new ClearedByDto(userId, nickname, role) + ); + } +} diff --git a/src/main/java/com/back/domain/chat/room/dto/ChatClearedNotification.java b/src/main/java/com/back/domain/chat/room/dto/ChatClearedNotification.java new file mode 100644 index 00000000..f26c2029 --- /dev/null +++ b/src/main/java/com/back/domain/chat/room/dto/ChatClearedNotification.java @@ -0,0 +1,37 @@ +package com.back.domain.chat.room.dto; + +/** + * WebSocket 브로드캐스트용 채팅 삭제 알림 DTO + */ +public record ChatClearedNotification( + String type, + Long roomId, + java.time.LocalDateTime clearedAt, + ClearedByDto clearedBy, + Integer deletedCount, + String message +) { + + public record ClearedByDto( + Long userId, + String nickname, + String profileImageUrl, + String role + ) {} + + // 알림 생성 헬퍼 + public static ChatClearedNotification create(Long roomId, int deletedCount, + Long userId, String nickname, String profileImageUrl, String role) { + ClearedByDto clearedBy = new ClearedByDto(userId, nickname, profileImageUrl, role); + String message = nickname + "님이 모든 채팅을 삭제했습니다."; + + return new ChatClearedNotification( + "CHAT_CLEARED", + roomId, + java.time.LocalDateTime.now(), + clearedBy, + deletedCount, + message + ); + } +} diff --git a/src/main/java/com/back/domain/chat/room/service/RoomChatService.java b/src/main/java/com/back/domain/chat/room/service/RoomChatService.java index 3746bd7d..8999bf65 100644 --- a/src/main/java/com/back/domain/chat/room/service/RoomChatService.java +++ b/src/main/java/com/back/domain/chat/room/service/RoomChatService.java @@ -1,8 +1,12 @@ package com.back.domain.chat.room.service; +import com.back.domain.chat.room.dto.ChatClearedNotification; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; import com.back.domain.studyroom.repository.RoomChatMessageRepository; +import com.back.domain.studyroom.repository.RoomMemberRepository; import com.back.domain.studyroom.repository.RoomRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; @@ -27,6 +31,7 @@ public class RoomChatService { private final RoomChatMessageRepository roomChatMessageRepository; + private final RoomMemberRepository roomMemberRepository; private final RoomRepository roomRepository; private final UserRepository userRepository; private final CurrentUser currentUser; @@ -82,6 +87,49 @@ public RoomChatPageResponse getRoomChatHistory(Long roomId, int page, int size, return RoomChatPageResponse.from(messagesPage, convertedContent); } + // 방 채팅 메시지 전체 삭제 + @Transactional + public ChatClearedNotification.ClearedByDto clearRoomChat(Long roomId, Long userId) { + + // 방 존재 여부 확인 + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 방입니다")); + + // 사용자 존재 여부 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 사용자가 해당 방의 멤버인지 확인 + RoomMember roomMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + // 권한 확인 - 방장(HOST) 또는 부방장(SUB_HOST)만 가능 + if (!canManageChat(roomMember.getRole())) { + throw new SecurityException("채팅 삭제 권한이 없습니다"); + } + + try { + // 해당 방의 모든 채팅 메시지 삭제 + int deletedCount = roomChatMessageRepository.deleteAllByRoomId(roomId); + + // 삭제를 실행한 사용자 정보 반환 + return new ChatClearedNotification.ClearedByDto( + user.getId(), + user.getNickname(), + user.getProfileImageUrl(), + roomMember.getRole().name() + ); + + } catch (Exception e) { + throw new CustomException(ErrorCode.CHAT_DELETE_FAILED); + } + } + + // 채팅 관리 권한 확인 (방장 또는 부방장) + private boolean canManageChat(RoomRole role) { + return role == RoomRole.HOST || role == RoomRole.SUB_HOST; + } + // size 값 검증 및 최대값 제한 private int validateAndLimitPageSize(int size) { if (size <= 0) { @@ -105,4 +153,9 @@ private RoomChatMessageDto convertToDto(RoomChatMessage message) { ); } + // 방의 현재 채팅 메시지 수 조회 + public int getRoomChatCount(Long roomId) { + return roomChatMessageRepository.countByRoomId(roomId); + } + } \ No newline at end of file diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java index c41acc4a..898c27df 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java @@ -2,8 +2,21 @@ import com.back.domain.studyroom.entity.RoomChatMessage; 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 org.springframework.stereotype.Repository; @Repository public interface RoomChatMessageRepository extends JpaRepository, RoomChatMessageRepositoryCustom { + + // 특정 방의 채팅 메시지 수 조회 + @Query("SELECT COUNT(m) FROM RoomChatMessage m WHERE m.room.id = :roomId") + int countByRoomId(@Param("roomId") Long roomId); + + // 특정 방의 모든 채팅 메시지 삭제 + @Modifying + @Query("DELETE FROM RoomChatMessage m WHERE m.room.id = :roomId") + int deleteAllByRoomId(@Param("roomId") Long roomId); + } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java index 7b3817c0..7f50a04e 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java @@ -25,4 +25,8 @@ public interface RoomChatMessageRepositoryCustom { */ Page findMessagesByRoomIdBefore(Long roomId, LocalDateTime before, Pageable pageable); + /** + * 특정 방의 모든 채팅 메시지 삭제 + */ + int deleteAllMessagesByRoomId(Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java index 0859c0fa..4f8dd70f 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java @@ -80,4 +80,12 @@ public Page findMessagesByRoomIdBefore(Long roomId, LocalDateTi return new PageImpl<>(messages, pageable, totalCount != null ? totalCount : 0); } + @Override + public int deleteAllMessagesByRoomId(Long roomId) { + return Math.toIntExact(queryFactory + .delete(message) + .where(message.room.id.eq(roomId)) + .execute()); + } + } diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index b7520ce7..5700639d 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -31,6 +31,9 @@ public enum ErrorCode { NOT_ROOM_MANAGER(HttpStatus.FORBIDDEN, "ROOM_009", "방 관리자 권한이 필요합니다."), CANNOT_KICK_HOST(HttpStatus.BAD_REQUEST, "ROOM_010", "방장은 추방할 수 없습니다."), CANNOT_CHANGE_HOST_ROLE(HttpStatus.BAD_REQUEST, "ROOM_011", "방장의 권한은 변경할 수 없습니다."), + CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_012", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), + INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "ROOM_013", "삭제 확인 메시지가 일치하지 않습니다."), + CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_014", "채팅 삭제 중 오류가 발생했습니다."), // ======================== 스터디 플래너 관련 ======================== PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), @@ -42,6 +45,8 @@ public enum ErrorCode { // ======================== 메시지 관련 ======================== MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."), MESSAGE_FORBIDDEN(HttpStatus.FORBIDDEN, "MESSAGE_002", "자신의 메시지만 삭제할 수 있습니다."), + MESSAGE_NOT_IN_ROOM(HttpStatus.BAD_REQUEST, "MESSAGE_003", "해당 방의 메시지가 아닙니다."), + MESSAGE_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "MESSAGE_004", "메시지를 삭제할 권한이 없습니다."), // ======================== WebSocket 관련 ======================== WS_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "WS_001", "존재하지 않는 방입니다"), @@ -52,11 +57,20 @@ public enum ErrorCode { WS_ROOM_JOIN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WS_006", "방 입장 처리 중 오류가 발생했습니다."), WS_ROOM_LEAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WS_007", "방 퇴장 처리 중 오류가 발생했습니다."), WS_ACTIVITY_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WS_008", "활동 시간 업데이트 중 오류가 발생했습니다."), + WS_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "WS_009", "WebSocket 인증이 필요합니다."), + WS_FORBIDDEN(HttpStatus.FORBIDDEN, "WS_010", "WebSocket 접근 권한이 없습니다."), + WS_INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "WS_011", "삭제 확인 메시지가 일치하지 않습니다."), + WS_CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "WS_012", "채팅 삭제 중 오류가 발생했습니다."), + WS_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "WS_013", "WebSocket 사용자를 찾을 수 없습니다."), + WS_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "WS_014", "잘못된 WebSocket 요청입니다."), + WS_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WS_015", "WebSocket 내부 오류가 발생했습니다."), + WS_CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "WS_016", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), // ======================== 공통 에러 ======================== BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청하신 리소스를 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 내부 오류가 발생했습니다."), // ======================== 인증/인가 에러 ======================== UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH_001", "인증이 필요합니다."), @@ -70,7 +84,6 @@ public enum ErrorCode { OAUTH2_ATTRIBUTE_MISSING(HttpStatus.UNAUTHORIZED, "AUTH_009", "소셜 계정에서 필요한 사용자 정보를 가져올 수 없습니다."), OAUTH2_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_010", "소셜 로그인 인증에 실패했습니다."); - private final HttpStatus status; private final String code; private final String message; diff --git a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java index a2e7b82a..22a9e6b4 100644 --- a/src/main/java/com/back/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/back/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,8 @@ import com.back.global.common.dto.RsData; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -26,4 +28,33 @@ public ResponseEntity> handleValidationException(MethodArgumentNotV .status(HttpStatus.BAD_REQUEST) .body(RsData.fail(ErrorCode.BAD_REQUEST)); } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(RsData.fail(ErrorCode.BAD_REQUEST)); + } + + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleSecurityException(SecurityException ex) { + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(RsData.fail(ErrorCode.FORBIDDEN)); + } + + @ExceptionHandler({AuthorizationDeniedException.class, AccessDeniedException.class}) + public ResponseEntity> handleAccessDenied(Exception ex) { + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(RsData.fail(ErrorCode.FORBIDDEN)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(RsData.fail(ErrorCode.INTERNAL_SERVER_ERROR)); + } + } diff --git a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java index f84b78a5..6316fbbb 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java @@ -3,11 +3,13 @@ import com.back.global.exception.CustomException; import com.back.global.websocket.dto.HeartbeatMessage; import com.back.global.websocket.service.WebSocketSessionManager; +import com.back.global.websocket.util.WebSocketErrorHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Controller; @Slf4j @@ -16,10 +18,12 @@ public class WebSocketMessageController { private final WebSocketSessionManager sessionManager; + private final WebSocketErrorHelper errorHelper; // Heartbeat 처리 @MessageMapping("/heartbeat") - public void handleHeartbeat(@Payload HeartbeatMessage message) { + public void handleHeartbeat(@Payload HeartbeatMessage message, + SimpMessageHeaderAccessor headerAccessor) { try { if (message.userId() != null) { // TTL 10분으로 연장 @@ -27,61 +31,79 @@ public void handleHeartbeat(@Payload HeartbeatMessage message) { log.debug("Heartbeat 처리 완료 - 사용자: {}", message.userId()); } else { log.warn("유효하지 않은 Heartbeat 메시지 수신: userId가 null"); + errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); } } catch (CustomException e) { log.error("Heartbeat 처리 실패: {}", e.getMessage()); - // STOMP에서는 에러 응답을 보내지 않고 로깅만 (연결 유지) + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); } catch (Exception e) { log.error("Heartbeat 처리 중 예상치 못한 오류", e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Heartbeat 처리 중 오류가 발생했습니다"); } } // 방 입장 처리 @MessageMapping("/rooms/{roomId}/join") - public void handleJoinRoom(@DestinationVariable Long roomId, @Payload HeartbeatMessage message) { + public void handleJoinRoom(@DestinationVariable Long roomId, + @Payload HeartbeatMessage message, + SimpMessageHeaderAccessor headerAccessor) { try { if (message.userId() != null) { sessionManager.joinRoom(message.userId(), roomId); log.info("STOMP 방 입장 처리 완료 - 사용자: {}, 방: {}", message.userId(), roomId); } else { log.warn("유효하지 않은 방 입장 요청: userId가 null"); + errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); } } catch (CustomException e) { log.error("방 입장 처리 실패 - 방: {}, 에러: {}", roomId, e.getMessage()); + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); } catch (Exception e) { log.error("방 입장 처리 중 예상치 못한 오류 - 방: {}", roomId, e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "방 입장 중 오류가 발생했습니다"); } } // 방 퇴장 처리 @MessageMapping("/rooms/{roomId}/leave") - public void handleLeaveRoom(@DestinationVariable Long roomId, @Payload HeartbeatMessage message) { + public void handleLeaveRoom(@DestinationVariable Long roomId, + @Payload HeartbeatMessage message, + SimpMessageHeaderAccessor headerAccessor) { try { if (message.userId() != null) { sessionManager.leaveRoom(message.userId(), roomId); log.info("STOMP 방 퇴장 처리 완료 - 사용자: {}, 방: {}", message.userId(), roomId); } else { log.warn("유효하지 않은 방 퇴장 요청: userId가 null"); + errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); } } catch (CustomException e) { log.error("방 퇴장 처리 실패 - 방: {}, 에러: {}", roomId, e.getMessage()); + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); } catch (Exception e) { log.error("방 퇴장 처리 중 예상치 못한 오류 - 방: {}", roomId, e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "방 퇴장 중 오류가 발생했습니다"); } } - // 활동 신호 처리 + // 사용자 활동 신호 처리 @MessageMapping("/activity") - public void handleActivity(@Payload HeartbeatMessage message) { + public void handleActivity(@Payload HeartbeatMessage message, + SimpMessageHeaderAccessor headerAccessor) { try { if (message.userId() != null) { sessionManager.updateLastActivity(message.userId()); log.debug("사용자 활동 신호 처리 완료 - 사용자: {}", message.userId()); + } else { + log.warn("유효하지 않은 활동 신호: userId가 null"); + errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); } } catch (CustomException e) { log.error("활동 신호 처리 실패: {}", e.getMessage()); + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); } catch (Exception e) { log.error("활동 신호 처리 중 예상치 못한 오류", e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "활동 신호 처리 중 오류가 발생했습니다"); } } } \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/util/WebSocketErrorHelper.java b/src/main/java/com/back/global/websocket/util/WebSocketErrorHelper.java new file mode 100644 index 00000000..1053371c --- /dev/null +++ b/src/main/java/com/back/global/websocket/util/WebSocketErrorHelper.java @@ -0,0 +1,49 @@ +package com.back.global.websocket.util; + +import com.back.global.exception.CustomException; +import com.back.global.websocket.dto.WebSocketErrorResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +/** + * WebSocket 에러 처리 헬퍼 클래스 + * WebSocket Controller에서 공통으로 사용하는 에러 처리 로직 제공 + */ +@Component +@RequiredArgsConstructor +public class WebSocketErrorHelper { + + private final SimpMessagingTemplate messagingTemplate; + + // 특정 사용자에게 에러 메시지 전송 + public void sendErrorToUser(String sessionId, String errorCode, String errorMessage) { + WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create(errorCode, errorMessage); + messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse); + } + + // CustomException을 WebSocket 에러로 전송 + public void sendCustomExceptionToUser(String sessionId, CustomException exception) { + String errorCode = switch (exception.getErrorCode()) { + case CHAT_DELETE_FORBIDDEN -> "WS_016"; + default -> exception.getErrorCode().getCode(); + }; + + sendErrorToUser(sessionId, errorCode, exception.getMessage()); + } + + // 일반 Exception을 기본 WebSocket 에러로 전송 + public void sendGenericErrorToUser(String sessionId, Exception exception, String defaultMessage) { + sendErrorToUser(sessionId, "WS_015", defaultMessage); // WS_INTERNAL_ERROR + } + + // 인증 실패 에러 전송 + public void sendUnauthorizedError(String sessionId) { + sendErrorToUser(sessionId, "WS_009", "인증이 필요합니다"); + } + + // 잘못된 요청 에러 전송 + public void sendInvalidRequestError(String sessionId, String message) { + sendErrorToUser(sessionId, "WS_014", message); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java index c955f237..0d307141 100644 --- a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java +++ b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java @@ -1,9 +1,12 @@ package com.back.domain.chat.room.controller; +import com.back.domain.chat.room.dto.ChatClearedNotification; import com.back.domain.chat.room.dto.RoomChatPageResponse; import com.back.domain.chat.room.service.RoomChatService; -import com.back.global.security.user.CustomUserDetails; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; import com.back.global.security.jwt.JwtTokenProvider; +import com.back.global.security.user.CustomUserDetails; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,6 +15,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -19,7 +23,10 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -39,6 +46,9 @@ class RoomChatApiControllerTest { @MockitoBean private RoomChatService roomChatService; + @MockitoBean + private SimpMessagingTemplate messagingTemplate; + @Test @DisplayName("채팅 기록 조회 성공 - JWT 토큰 있음") void t1() throws Exception { @@ -114,7 +124,7 @@ void t3() throws Exception { @Test @DisplayName("잘못된 JWT 토큰으로 요청 - 401 Unauthorized") void t4() throws Exception { - given(jwtTokenProvider.validateToken("invalidtoken")).willReturn(false); + given(jwtTokenProvider.validateAccessToken("invalidtoken")).willReturn(false); mockMvc.perform(get("/api/rooms/1/messages") .param("page", "0") @@ -147,4 +157,284 @@ void t5() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.pageable.size").value(100)); // 100으로 제한됨 } + + @Test + @DisplayName("채팅 전체 삭제 성공 - 방장 권한") + void t6() throws Exception { + Long roomId = 1L; + Long userId = 1L; + int messageCount = 15; + + // Mock 설정 + ChatClearedNotification.ClearedByDto clearedByInfo = new ChatClearedNotification.ClearedByDto( + userId, "방장", "https://example.com/profile.jpg", "HOST" + ); + + given(roomChatService.getRoomChatCount(roomId)).willReturn(messageCount); + given(roomChatService.clearRoomChat(roomId, userId)).willReturn(clearedByInfo); + + // JWT 관련 스텁 + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(userId) + .username("방장") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))) + ); + + String requestBody = """ + { + "confirmMessage": "모든 채팅을 삭제하겠습니다" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", roomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("채팅 메시지 일괄 삭제 완료")) + .andExpect(jsonPath("$.data.roomId").value(roomId)) + .andExpect(jsonPath("$.data.deletedCount").value(messageCount)) + .andExpect(jsonPath("$.data.clearedBy.userId").value(userId)) + .andExpect(jsonPath("$.data.clearedBy.nickname").value("방장")) + .andExpect(jsonPath("$.data.clearedBy.role").value("HOST")); + + // WebSocket 메시지가 전송되었는지 확인 + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/chat-cleared"), + any(ChatClearedNotification.class) + ); + } + + @Test + @DisplayName("채팅 전체 삭제 성공 - 부방장 권한") + void t7() throws Exception { + Long roomId = 2L; + Long userId = 2L; + int messageCount = 8; + + ChatClearedNotification.ClearedByDto clearedByInfo = new ChatClearedNotification.ClearedByDto( + userId, "부방장", "https://example.com/sub-host.jpg", "SUB_HOST" + ); + + given(roomChatService.getRoomChatCount(roomId)).willReturn(messageCount); + given(roomChatService.clearRoomChat(roomId, userId)).willReturn(clearedByInfo); + + // JWT 관련 스텁 + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(userId) + .username("부방장") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))) + ); + + String requestBody = """ + { + "confirmMessage": "모든 채팅을 삭제하겠습니다" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", roomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.clearedBy.role").value("SUB_HOST")); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 권한 없음 (일반 멤버)") + void t8() throws Exception { + Long roomId = 1L; + Long userId = 3L; + + willThrow(new CustomException(ErrorCode.CHAT_DELETE_FORBIDDEN)) + .given(roomChatService).clearRoomChat(roomId, userId); + + // JWT 관련 스텁 + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(userId) + .username("일반멤버") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + String requestBody = """ + { + "confirmMessage": "모든 채팅을 삭제하겠습니다" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", roomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ROOM_012")) + .andExpect(jsonPath("$.message").value("채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다.")); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 존재하지 않는 방") + void t9() throws Exception { + Long nonExistentRoomId = 999L; + Long userId = 1L; + + willThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)) + .given(roomChatService).clearRoomChat(nonExistentRoomId, userId); + + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(userId) + .username("방장") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + String requestBody = """ + { + "confirmMessage": "모든 채팅을 삭제하겠습니다" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", nonExistentRoomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("ROOM_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 방입니다.")); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 잘못된 확인 메시지") + void t10() throws Exception { + Long roomId = 1L; + Long userId = 1L; + + willThrow(new CustomException(ErrorCode.INVALID_DELETE_CONFIRMATION)) + .given(roomChatService).clearRoomChat(roomId, userId); + + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(userId) + .username("방장") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + String requestBody = """ + { + "confirmMessage": "잘못된 확인 메시지" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", roomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("ROOM_013")) + .andExpect(jsonPath("$.message").value("삭제 확인 메시지가 일치하지 않습니다.")); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 빈 확인 메시지") + void t11() throws Exception { + Long roomId = 1L; + + // JWT 관련 스텁 + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(1L) + .username("방장") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))) + ); + + String requestBody = """ + { + "confirmMessage": "" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", roomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 방 멤버가 아님") + void t12() throws Exception { + Long roomId = 1L; + Long userId = 5L; + + willThrow(new CustomException(ErrorCode.NOT_ROOM_MEMBER)) + .given(roomChatService).clearRoomChat(roomId, userId); + + given(jwtTokenProvider.validateAccessToken("faketoken")).willReturn(true); + + CustomUserDetails mockUser = CustomUserDetails.builder() + .userId(userId) + .username("외부사용자") + .build(); + + given(jwtTokenProvider.getAuthentication("faketoken")) + .willReturn(new UsernamePasswordAuthenticationToken( + mockUser, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + String requestBody = """ + { + "confirmMessage": "모든 채팅을 삭제하겠습니다" + } + """; + + mockMvc.perform(delete("/api/rooms/{roomId}/messages", roomId) + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ROOM_008")) + .andExpect(jsonPath("$.message").value("방 멤버가 아닙니다.")); + } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/chat/room/controller/RoomChatWebSocketControllerTest.java b/src/test/java/com/back/domain/chat/room/controller/RoomChatWebSocketControllerTest.java index 89306554..b08593b7 100644 --- a/src/test/java/com/back/domain/chat/room/controller/RoomChatWebSocketControllerTest.java +++ b/src/test/java/com/back/domain/chat/room/controller/RoomChatWebSocketControllerTest.java @@ -1,12 +1,16 @@ package com.back.domain.chat.room.controller; +import com.back.domain.chat.room.dto.ChatClearRequest; +import com.back.domain.chat.room.dto.ChatClearedNotification; import com.back.domain.chat.room.dto.RoomChatMessageDto; import com.back.domain.chat.room.service.RoomChatService; import com.back.domain.studyroom.entity.RoomChatMessage; import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; import com.back.global.security.user.CustomUserDetails; -import com.back.global.websocket.dto.WebSocketErrorResponse; +import com.back.global.websocket.util.WebSocketErrorHelper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -27,7 +31,6 @@ import static org.mockito.BDDMockito.*; @ExtendWith(MockitoExtension.class) -@DisplayName("RoomChatWebSocketController 테스트") class RoomChatWebSocketControllerTest { @Mock @@ -39,6 +42,9 @@ class RoomChatWebSocketControllerTest { @Mock private SimpMessageHeaderAccessor headerAccessor; + @Mock + private WebSocketErrorHelper errorHelper; + @InjectMocks private RoomChatWebSocketController roomChatWebSocketController; @@ -109,7 +115,7 @@ private void setField(Object target, String fieldName, Object value) throws Exce } @Test - @DisplayName("정상적인 채팅 메시지 처리") + @DisplayName("WebSocket 채팅 전체 조회 성공") void t1() { // Given Long roomId = 1L; @@ -150,7 +156,7 @@ void t1() { } @Test - @DisplayName("인증되지 않은 사용자의 메시지 처리 - 에러 전송") + @DisplayName("WebSocket 채팅 전체 조회 실패 - 인증되지 않은 사용자의 메시지 처리") void t2() { Long roomId = 1L; @@ -172,18 +178,11 @@ void t2() { verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); // 에러 메시지가 해당 사용자에게만 전송되는지 확인 - verify(messagingTemplate).convertAndSendToUser( - eq("test-session-123"), - eq("/queue/errors"), - argThat((WebSocketErrorResponse errorResponse) -> - errorResponse.error().code().equals("WS_UNAUTHORIZED") && - errorResponse.error().message().equals("인증이 필요합니다") - ) - ); + verify(errorHelper).sendUnauthorizedError("test-session-123"); } @Test - @DisplayName("서비스 계층 예외 발생 시 에러 처리") + @DisplayName("WebSocket 채팅 전체 조회 실패 - 서비스 계층 예외 발생 시 에러 처리") void t3() { Long roomId = 1L; @@ -202,18 +201,15 @@ void t3() { // Then verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); - verify(messagingTemplate).convertAndSendToUser( + verify(errorHelper).sendGenericErrorToUser( eq("test-session-123"), - eq("/queue/errors"), - argThat((WebSocketErrorResponse errorResponse) -> - errorResponse.error().code().equals("WS_ROOM_NOT_FOUND") && - errorResponse.error().message().equals("존재하지 않는 방입니다") - ) + any(RuntimeException.class), + eq("메시지 전송 중 오류가 발생했습니다") ); } @Test - @DisplayName("잘못된 Principal 타입 처리") + @DisplayName("WebSocket 채팅 전체 조회 실패 - 잘못된 Principal 타입 처리") void t4() { Long roomId = 1L; @@ -234,15 +230,12 @@ void t4() { verify(roomChatService, never()).saveRoomChatMessage(any()); verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); - verify(messagingTemplate).convertAndSendToUser( - eq("test-session-123"), - eq("/queue/errors"), - any(WebSocketErrorResponse.class) - ); + // 실제 호출되는 sendUnauthorizedError 검증 + verify(errorHelper).sendUnauthorizedError("test-session-123"); } @Test - @DisplayName("CustomUserDetails가 아닌 Principal 객체 처리") + @DisplayName("WebSocket 채팅 전체 조회 실패 - CustomUserDetails가 아닌 Principal 객체 처리") void t5() { Long roomId = 1L; @@ -264,10 +257,200 @@ void t5() { verify(roomChatService, never()).saveRoomChatMessage(any()); verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); - verify(messagingTemplate).convertAndSendToUser( + verify(errorHelper).sendUnauthorizedError("test-session-123"); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 성공 - 방장 권한") + void t6() { + Long roomId = 1L; + int messageCount = 25; + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + + ChatClearedNotification.ClearedByDto clearedByInfo = + new ChatClearedNotification.ClearedByDto(1L, "방장", "https://example.com/host.jpg", "HOST"); + + given(roomChatService.getRoomChatCount(roomId)).willReturn(messageCount); + given(roomChatService.clearRoomChat(roomId, 1L)).willReturn(clearedByInfo); + + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, testPrincipal); + + verify(messagingTemplate).convertAndSend(eq("/topic/room/" + roomId + "/chat-cleared"), + argThat((ChatClearedNotification notification) -> + notification.roomId().equals(roomId) && + notification.deletedCount().equals(messageCount) && + notification.clearedBy().userId().equals(1L) && + notification.clearedBy().nickname().equals("방장") && + notification.clearedBy().role().equals("HOST") + )); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 성공 - 부방장 권한") + void t7() { + Long roomId = 2L; + int messageCount = 10; + + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + + ChatClearedNotification.ClearedByDto clearedByInfo = new ChatClearedNotification.ClearedByDto( + 1L, "부방장", "https://example.com/subhost.jpg", "SUB_HOST" + ); + + // Mock 설정 + given(roomChatService.getRoomChatCount(roomId)).willReturn(messageCount); + given(roomChatService.clearRoomChat(roomId, 1L)).willReturn(clearedByInfo); + + // When + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, testPrincipal); + + // Then + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/chat-cleared"), + argThat((ChatClearedNotification notification) -> + notification.clearedBy().role().equals("SUB_HOST") && + notification.clearedBy().nickname().equals("부방장") + ) + ); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 실패 - 인증되지 않은 사용자") + void t8() { + Long roomId = 1L; + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + Principal invalidPrincipal = null; + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + + // When + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, invalidPrincipal); + + // Then + verify(roomChatService, never()).getRoomChatCount(any()); + verify(roomChatService, never()).clearRoomChat(any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + + verify(errorHelper).sendUnauthorizedError("test-session-123"); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 실패 - 잘못된 확인 메시지") + void t9() { + Long roomId = 1L; + ChatClearRequest invalidRequest = new ChatClearRequest("잘못된 확인 메시지"); + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + + // When + roomChatWebSocketController.clearRoomChat(roomId, invalidRequest, headerAccessor, testPrincipal); + + // Then + verify(roomChatService, never()).getRoomChatCount(any()); + verify(roomChatService, never()).clearRoomChat(any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + + verify(errorHelper).sendErrorToUser( + eq("test-session-123"), + eq("WS_011"), + eq("삭제 확인 메시지가 일치하지 않습니다") + ); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 실패 - 권한 없음 (일반 멤버)") + void t10() { + Long roomId = 1L; + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + given(roomChatService.getRoomChatCount(roomId)).willReturn(5); + given(roomChatService.clearRoomChat(roomId, 1L)) + .willThrow(new SecurityException("채팅 삭제 권한이 없습니다")); + + // When + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, testPrincipal); + + // Then + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + verify(errorHelper).sendGenericErrorToUser( + eq("test-session-123"), + any(SecurityException.class), + eq("채팅 삭제 중 오류가 발생했습니다") + ); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 실패 - 방 멤버가 아님") + void t11() { + Long roomId = 1L; + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + given(roomChatService.getRoomChatCount(roomId)).willReturn(5); + given(roomChatService.clearRoomChat(roomId, 1L)) + .willThrow(new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + // When + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, testPrincipal); + + // Then + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + verify(errorHelper).sendCustomExceptionToUser( eq("test-session-123"), - eq("/queue/errors"), - any(WebSocketErrorResponse.class) + any(CustomException.class) + ); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 실패 - 존재하지 않는 방") + void t12() { + Long roomId = 999L; + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + given(roomChatService.getRoomChatCount(roomId)).willReturn(0); + given(roomChatService.clearRoomChat(roomId, 1L)) + .willThrow(new IllegalArgumentException("존재하지 않는 방입니다")); + + // When + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, testPrincipal); + + // Then + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + verify(errorHelper).sendGenericErrorToUser( + eq("test-session-123"), + any(IllegalArgumentException.class), + eq("채팅 삭제 중 오류가 발생했습니다") + ); + } + + @Test + @DisplayName("WebSocket 채팅 전체 삭제 - 메시지 수가 0인 경우") + void t13() { + Long roomId = 3L; + int messageCount = 0; // 메시지가 없는 경우 + + ChatClearRequest request = new ChatClearRequest("모든 채팅을 삭제하겠습니다"); + + ChatClearedNotification.ClearedByDto clearedByInfo = new ChatClearedNotification.ClearedByDto( + 1L, "방장", "https://example.com/host.jpg", "HOST" + ); + + // Mock 설정 + given(roomChatService.getRoomChatCount(roomId)).willReturn(messageCount); + given(roomChatService.clearRoomChat(roomId, 1L)).willReturn(clearedByInfo); + + // When + roomChatWebSocketController.clearRoomChat(roomId, request, headerAccessor, testPrincipal); + + // Then + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/chat-cleared"), + argThat((ChatClearedNotification notification) -> + notification.deletedCount().equals(0) && + notification.message().contains("방장님이 모든 채팅을 삭제했습니다.") + ) ); } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java b/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java index 9854e1f0..c8bec684 100644 --- a/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java +++ b/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java @@ -1,10 +1,14 @@ package com.back.domain.chat.room.service; +import com.back.domain.chat.room.dto.ChatClearedNotification; import com.back.domain.chat.room.dto.RoomChatMessageDto; import com.back.domain.chat.room.dto.RoomChatPageResponse; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; import com.back.domain.studyroom.repository.RoomChatMessageRepository; +import com.back.domain.studyroom.repository.RoomMemberRepository; import com.back.domain.studyroom.repository.RoomRepository; import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; @@ -48,6 +52,9 @@ class RoomChatServiceTest { @Mock private UserRepository userRepository; + @Mock + private RoomMemberRepository roomMemberRepository; + @InjectMocks private RoomChatService roomChatService; @@ -395,4 +402,187 @@ void t12() throws Exception { assertThat(result.attachment()).isNull(); assertThat(result.createdAt()).isNotNull(); } + + // ==================== 채팅 전체 삭제 기능 테스트 ==================== + + @Test + @DisplayName("채팅 전체 삭제 성공 - 방장 권한") + void t13() { + Long roomId = 1L; + Long userId = 1L; + int deletedCount = 15; + + // RoomMember 생성 (방장) + RoomMember hostMember = RoomMember.createHost(testRoom, testUser); + + // Mock 설정 + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(hostMember)); + given(roomChatMessageRepository.deleteAllByRoomId(roomId)).willReturn(deletedCount); + + // When + ChatClearedNotification.ClearedByDto result = roomChatService.clearRoomChat(roomId, userId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.nickname()).isEqualTo("테스터"); + assertThat(result.profileImageUrl()).isEqualTo("https://example.com/profile.jpg"); + assertThat(result.role()).isEqualTo("HOST"); + + verify(roomRepository).findById(roomId); + verify(userRepository).findById(userId); + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, userId); + verify(roomChatMessageRepository).deleteAllByRoomId(roomId); + } + + @Test + @DisplayName("채팅 전체 삭제 성공 - 부방장 권한") + void t14() { + Long roomId = 1L; + Long userId = 1L; + int deletedCount = 8; + + // RoomMember 생성 (부방장) + RoomMember subHostMember = RoomMember.create(testRoom, testUser, RoomRole.SUB_HOST); + + // Mock 설정 + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(subHostMember)); + given(roomChatMessageRepository.deleteAllByRoomId(roomId)).willReturn(deletedCount); + + // When + ChatClearedNotification.ClearedByDto result = roomChatService.clearRoomChat(roomId, userId); + + // Then + assertThat(result.role()).isEqualTo("SUB_HOST"); + verify(roomChatMessageRepository).deleteAllByRoomId(roomId); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 존재하지 않는 방") + void t15() { + Long nonExistentRoomId = 999L; + Long userId = 1L; + + given(roomRepository.findById(nonExistentRoomId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> roomChatService.clearRoomChat(nonExistentRoomId, userId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 방입니다"); + + verify(roomRepository).findById(nonExistentRoomId); + verify(userRepository, never()).findById(any()); + verify(roomMemberRepository, never()).findByRoomIdAndUserId(any(), any()); + verify(roomChatMessageRepository, never()).deleteAllByRoomId(any()); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 존재하지 않는 사용자") + void t16() { + Long roomId = 1L; + Long nonExistentUserId = 999L; + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(nonExistentUserId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> roomChatService.clearRoomChat(roomId, nonExistentUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + + verify(roomRepository).findById(roomId); + verify(userRepository).findById(nonExistentUserId); + verify(roomMemberRepository, never()).findByRoomIdAndUserId(any(), any()); + verify(roomChatMessageRepository, never()).deleteAllByRoomId(any()); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 방 멤버가 아님") + void t17() { + Long roomId = 1L; + Long userId = 1L; + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, userId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> roomChatService.clearRoomChat(roomId, userId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, userId); + verify(roomChatMessageRepository, never()).deleteAllByRoomId(any()); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - 권한 없음 (일반 멤버)") + void t18() { + Long roomId = 1L; + Long userId = 1L; + + // RoomMember 생성 (일반 멤버) + RoomMember memberMember = RoomMember.createMember(testRoom, testUser); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(memberMember)); + + assertThatThrownBy(() -> roomChatService.clearRoomChat(roomId, userId)) + .isInstanceOf(SecurityException.class) + .hasMessage("채팅 삭제 권한이 없습니다"); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, userId); + verify(roomChatMessageRepository, never()).deleteAllByRoomId(any()); + } + + @Test + @DisplayName("채팅 전체 삭제 실패 - DB 삭제 오류") + void t19() { + Long roomId = 1L; + Long userId = 1L; + + RoomMember hostMember = RoomMember.createHost(testRoom, testUser); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(hostMember)); + given(roomChatMessageRepository.deleteAllByRoomId(roomId)) + .willThrow(new RuntimeException("DB 연결 오류")); + + assertThatThrownBy(() -> roomChatService.clearRoomChat(roomId, userId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CHAT_DELETE_FAILED); + + verify(roomChatMessageRepository).deleteAllByRoomId(roomId); + } + + @Test + @DisplayName("방의 채팅 메시지 수 조회 성공") + void t20() { + Long roomId = 1L; + int expectedCount = 42; + + given(roomChatMessageRepository.countByRoomId(roomId)).willReturn(expectedCount); + + int result = roomChatService.getRoomChatCount(roomId); + + assertThat(result).isEqualTo(expectedCount); + verify(roomChatMessageRepository).countByRoomId(roomId); + } + + @Test + @DisplayName("방의 채팅 메시지 수 조회 - 메시지가 없는 경우") + void t21() { + Long roomId = 2L; + int expectedCount = 0; + + given(roomChatMessageRepository.countByRoomId(roomId)).willReturn(expectedCount); + + int result = roomChatService.getRoomChatCount(roomId); + + assertThat(result).isEqualTo(0); + verify(roomChatMessageRepository).countByRoomId(roomId); + } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java index ff5f4df1..39852a0c 100644 --- a/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java +++ b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java @@ -236,4 +236,125 @@ void t8() { assertThat(result.getContent()).isEmpty(); // 모든 메시지가 과거 시간보다 이후이므로 빈 결과 assertThat(result.getTotalElements()).isEqualTo(0); } + + // ==================== 채팅 전체 삭제 기능 테스트 ==================== + + @Test + @DisplayName("방별 채팅 메시지 수 조회") + void t9() { + int messageCount = roomChatMessageRepository.countByRoomId(testRoom.getId()); + + assertThat(messageCount).isEqualTo(10); // setUp에서 10개 메시지 생성 + } + + @Test + @DisplayName("존재하지 않는 방의 채팅 메시지 수 조회") + void t10() { + Long nonExistentRoomId = 99999L; + + int messageCount = roomChatMessageRepository.countByRoomId(nonExistentRoomId); + + assertThat(messageCount).isEqualTo(0); + } + + @Test + @DisplayName("방별 모든 채팅 메시지 삭제") + void t11() { + Long roomId = testRoom.getId(); + + // 삭제 전 메시지 수 확인 + int countBeforeDelete = roomChatMessageRepository.countByRoomId(roomId); + assertThat(countBeforeDelete).isEqualTo(10); + + // 메시지 삭제 실행 + int deletedCount = roomChatMessageRepository.deleteAllByRoomId(roomId); + + // 삭제된 수 검증 + assertThat(deletedCount).isEqualTo(10); + + // 삭제 후 메시지 수 확인 + int countAfterDelete = roomChatMessageRepository.countByRoomId(roomId); + assertThat(countAfterDelete).isEqualTo(0); + + // 실제 조회로도 확인 + Pageable pageable = PageRequest.of(0, 10); + Page result = roomChatMessageRepository.findMessagesByRoomId(roomId, pageable); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("존재하지 않는 방의 채팅 메시지 삭제") + void t12() { + Long nonExistentRoomId = 99999L; + + int deletedCount = roomChatMessageRepository.deleteAllByRoomId(nonExistentRoomId); + + assertThat(deletedCount).isEqualTo(0); // 삭제할 메시지가 없으므로 0 + } + + @Test + @DisplayName("여러 방이 있을 때 특정 방만 삭제") + void t13() { + // 추가 방 생성 + Room anotherRoom = Room.builder() + .title("다른 스터디룸") + .description("다른 테스트용 방") + .maxParticipants(5) + .build(); + testEntityManager.persistAndFlush(anotherRoom); + + // 다른 방에 메시지 추가 + for (int i = 0; i < 5; i++) { + RoomChatMessage message = new RoomChatMessage( + anotherRoom, + testUser1, + "다른 방 메시지 " + (i + 1) + ); + testEntityManager.persist(message); + } + testEntityManager.flush(); + + // 첫 번째 방의 메시지만 삭제 + int deletedCount = roomChatMessageRepository.deleteAllByRoomId(testRoom.getId()); + + assertThat(deletedCount).isEqualTo(10); // 첫 번째 방의 메시지만 삭제 + + // 첫 번째 방 메시지 확인 + int firstRoomCount = roomChatMessageRepository.countByRoomId(testRoom.getId()); + assertThat(firstRoomCount).isEqualTo(0); + + // 두 번째 방 메시지는 그대로 유지되는지 확인 + int secondRoomCount = roomChatMessageRepository.countByRoomId(anotherRoom.getId()); + assertThat(secondRoomCount).isEqualTo(5); + } + + @Test + @DisplayName("트랜잭션 롤백 시 삭제 취소 확인") + void t14() { + Long roomId = testRoom.getId(); + + // 삭제 전 메시지 수 확인 + int countBefore = roomChatMessageRepository.countByRoomId(roomId); + assertThat(countBefore).isEqualTo(10); + + // 트랜잭션을 명시적으로 롤백하는 테스트는 실제 트랜잭션 매니저가 필요하므로 여기서는 생략 + // 대신 삭제 후 다시 메시지를 생성해서 테스트 + + roomChatMessageRepository.deleteAllByRoomId(roomId); + + // 다시 메시지 생성 (롤백 시뮬레이션) + for (int i = 0; i < 3; i++) { + RoomChatMessage message = new RoomChatMessage( + testRoom, + testUser1, + "복구된 메시지 " + (i + 1) + ); + testEntityManager.persist(message); + } + testEntityManager.flush(); + + int countAfter = roomChatMessageRepository.countByRoomId(roomId); + assertThat(countAfter).isEqualTo(3); + } } \ No newline at end of file