From ab8638498c2f9f29d88ca92f8cd462b795d24cf0 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 23 Sep 2025 19:49:16 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Feat:=20=EC=99=80=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=9E=84=20=ED=99=95=EC=9D=B8=20=ED=9B=84=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatApiController.java | 24 ------------------- .../controller/ChatWebSocketController.java | 1 + .../back/domain/chat/service/ChatService.java | 22 +---------------- 3 files changed, 2 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/back/domain/chat/controller/ChatApiController.java b/src/main/java/com/back/domain/chat/controller/ChatApiController.java index 48185403..d1ba6eb9 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatApiController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatApiController.java @@ -42,28 +42,4 @@ public ResponseEntity> getRoomChatMessages( .body(RsData.success("채팅 기록 조회 성공", chatHistory)); } - // 방 채팅 메시지 삭제 - @DeleteMapping("/rooms/{roomId}/messages/{messageId}") - public ResponseEntity>> deleteRoomMessage( - @PathVariable Long roomId, - @PathVariable Long messageId, - @RequestHeader("Authorization") String authorization) { - - // TODO: JWT 토큰에서 사용자 정보 추출 - - // 임시로 하드코딩 (테스트용) - Long currentUserId = 1L; - - // 메시지 삭제 로직 실행 - chatService.deleteRoomMessage(roomId, messageId, currentUserId); - - Map responseData = Map.of( - "messageId", messageId, - "deletedAt", LocalDateTime.now() - ); - - 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/controller/ChatWebSocketController.java b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java index a21e40ec..e728d4f6 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java @@ -74,4 +74,5 @@ public void handleRoomChat(@DestinationVariable Long roomId, messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse); } } + } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chat/service/ChatService.java b/src/main/java/com/back/domain/chat/service/ChatService.java index aaeec3e0..b5a2dfa4 100644 --- a/src/main/java/com/back/domain/chat/service/ChatService.java +++ b/src/main/java/com/back/domain/chat/service/ChatService.java @@ -85,24 +85,4 @@ private ChatMessageDto convertToDto(RoomChatMessage message) { .build(); } - // 방 채팅 메시지 삭제 - @Transactional - public void deleteRoomMessage(Long roomId, Long messageId, Long currentUserId) { - // 메시지 존재 여부 확인 - RoomChatMessage message = roomChatMessageRepository.findById(messageId) - .orElseThrow(() -> new CustomException(ErrorCode.MESSAGE_NOT_FOUND)); - - // 방 ID 검증 - if (!message.getRoom().getId().equals(roomId)) { - throw new CustomException(ErrorCode.BAD_REQUEST); - } - - // 작성자 권한 확인 - if (!message.getUser().getId().equals(currentUserId)) { - throw new CustomException(ErrorCode.MESSAGE_FORBIDDEN); - } - - // 메시지 삭제 - roomChatMessageRepository.delete(message); - } -} \ No newline at end of file +} From d5deb9623c3a44025e6acbf2cf9d394990d50c8e Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Wed, 24 Sep 2025 10:20:56 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Feat:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EC=B1=84=ED=8C=85=EC=97=90=20JWT=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20REST?= =?UTF-8?q?=20API=EC=97=90=20@AuthenticationPrincipal=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20-=20WebSocket=EC=97=90=EC=84=9C=20Principal=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatApiController.java | 8 +- .../controller/ChatWebSocketController.java | 37 ++++++- .../websocket/config/WebSocketConfig.java | 98 +++++++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/back/domain/chat/controller/ChatApiController.java b/src/main/java/com/back/domain/chat/controller/ChatApiController.java index d1ba6eb9..2c3e0cb5 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatApiController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatApiController.java @@ -3,18 +3,20 @@ import com.back.domain.chat.dto.ChatPageResponse; import com.back.domain.chat.service.ChatService; import com.back.global.common.dto.RsData; +import com.back.global.security.CustomUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.util.Map; @RestController -@RequestMapping("/api") @RequiredArgsConstructor +@RequestMapping("/api") public class ChatApiController { private final ChatService chatService; @@ -26,15 +28,13 @@ public ResponseEntity> getRoomChatMessages( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime before, - @RequestHeader("Authorization") String authorization) { + @AuthenticationPrincipal CustomUserDetails userDetails) { // size 최대값 제한 (임시: max 100) if (size > 100) { size = 100; } - // TODO: JWT 토큰에서 사용자 정보 추출 및 권한 확인 - ChatPageResponse chatHistory = chatService.getRoomChatHistory(roomId, page, size, before); return ResponseEntity diff --git a/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java index e728d4f6..80ca49d2 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java @@ -2,6 +2,7 @@ import com.back.domain.studyroom.entity.RoomChatMessage; import com.back.domain.chat.dto.ChatMessageDto; +import com.back.global.security.CustomUserDetails; import com.back.global.websocket.dto.WebSocketErrorResponse; import com.back.domain.chat.service.ChatService; import lombok.RequiredArgsConstructor; @@ -9,8 +10,11 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; +import java.security.Principal; + @Controller @RequiredArgsConstructor public class ChatWebSocketController { @@ -25,18 +29,24 @@ public class ChatWebSocketController { * @param roomId 스터디룸 ID * @param chatMessage 채팅 메시지 (content, messageType, attachmentId) * @param headerAccessor WebSocket 헤더 정보 + * @param principal 인증된 사용자 정보 */ @MessageMapping("/chat/room/{roomId}") public void handleRoomChat(@DestinationVariable Long roomId, ChatMessageDto chatMessage, - SimpMessageHeaderAccessor headerAccessor) { + SimpMessageHeaderAccessor headerAccessor, + Principal principal) { try { - // TODO: WebSocket 세션에서 사용자 정보 추출 + // WebSocket에서 인증된 사용자 정보 추출 + CustomUserDetails userDetails = extractUserDetails(principal); + if (userDetails == null) { + sendErrorToUser(headerAccessor.getSessionId(), "WS_UNAUTHORIZED", "인증이 필요합니다"); + return; + } - // 임시 하드코딩 (나중에 JWT 인증으로 교체) - Long currentUserId = 1L; - String currentUserNickname = "테스트사용자"; + Long currentUserId = userDetails.getUserId(); + String currentUserNickname = userDetails.getUsername(); // 메시지 정보 보완 chatMessage.setRoomId(roomId); @@ -75,4 +85,21 @@ public void handleRoomChat(@DestinationVariable Long roomId, } } + // WebSocket Principal에서 CustomUserDetails 추출 + private CustomUserDetails extractUserDetails(Principal principal) { + if (principal instanceof Authentication auth) { + Object principalObj = auth.getPrincipal(); + if (principalObj instanceof CustomUserDetails userDetails) { + return userDetails; + } + } + 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/global/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java index 9f9f9d60..d4b3f953 100644 --- a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -1,15 +1,34 @@ package com.back.global.websocket.config; +import com.back.global.security.CustomUserDetails; +import com.back.global.security.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.core.Authentication; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +@Slf4j @Configuration +@RequiredArgsConstructor @EnableWebSocketMessageBroker +@Order(Ordered.HIGHEST_PRECEDENCE + 99) public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final JwtTokenProvider jwtTokenProvider; + /** * 메시지 브로커 설정 * - /topic: 1:N 브로드캐스트 (방 채팅) @@ -33,4 +52,83 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { .setAllowedOriginPatterns("*") // 모든 도메인 허용 (개발용) .withSockJS(); // SockJS 사용 } + + /** + * WebSocket 메시지 채널 설정 + * JWT 인증 인터셉터 등록 + */ + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // JWT 인증 인터셉터 등록 + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor != null) { + log.debug("WebSocket 메시지 처리 - Command: {}, Destination: {}, SessionId: {}", + accessor.getCommand(), accessor.getDestination(), accessor.getSessionId()); + + // CONNECT 시점에서 JWT 토큰 인증 + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + authenticateUser(accessor); + } + + // SEND 시점에서도 인증 확인 (추가 보안) + else if (StompCommand.SEND.equals(accessor.getCommand())) { + validateAuthentication(accessor); + } + } + + return message; + } + }); + } + + /** + * WebSocket 연결 시 JWT 토큰 인증 + */ + private void authenticateUser(StompHeaderAccessor accessor) { + try { + // Authorization 헤더에서 JWT 토큰 추출 + String authHeader = accessor.getFirstNativeHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new RuntimeException("인증 토큰이 필요합니다"); + } + + String token = authHeader.substring(7); // "Bearer " 제거 + + // JWT 토큰 검증 + if (!jwtTokenProvider.validateToken(token)) { + throw new RuntimeException("유효하지 않은 인증 토큰입니다"); + } + + // 토큰에서 사용자 정보 추출 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + + // 세션에 사용자 정보 저장 + accessor.setUser(authentication); + + } catch (Exception e) { + throw new RuntimeException("WebSocket 인증에 실패했습니다: " + e.getMessage()); + } + } + + /** + * 메시지 전송 시 인증 상태 확인 + */ + private void validateAuthentication(StompHeaderAccessor accessor) { + if (accessor.getUser() == null) { + throw new RuntimeException("인증이 필요합니다"); + } + + // 인증된 사용자 정보 로깅 + Authentication auth = (Authentication) accessor.getUser(); + if (auth.getPrincipal() instanceof CustomUserDetails userDetails) { + log.debug("인증된 사용자 메시지 전송 - 사용자: {} (ID: {}), 목적지: {}", + userDetails.getUsername(), userDetails.getUserId(), accessor.getDestination()); + } + } } From fa54b451e05d96f560f78bb0644cf9de61aa2d7f Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Wed, 24 Sep 2025 11:39:36 +0900 Subject: [PATCH 3/6] =?UTF-8?q?Feat:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EC=B1=84=ED=8C=85=20Repository=20QueryDSL=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20-=20RoomChatMessageRepositoryCustom=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20QueryDSL=EB=A1=9C=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EB=B0=8F=20Fetch=20Join=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20-=20before=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EC=99=84=EC=A0=84=20=EC=A7=80=EC=9B=90=20(=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20TODO=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../back/domain/chat/service/ChatService.java | 5 +- .../repository/RoomChatMessageRepository.java | 31 +------ .../RoomChatMessageRepositoryCustom.java | 28 +++++++ .../RoomChatMessageRepositoryImpl.java | 83 +++++++++++++++++++ .../back/global/config/QueryDslConfig.java | 25 ++++++ .../config/WebSocketConfig.java | 2 +- 6 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java create mode 100644 src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java create mode 100644 src/main/java/com/back/global/config/QueryDslConfig.java rename src/main/java/com/back/global/{websocket => }/config/WebSocketConfig.java (99%) diff --git a/src/main/java/com/back/domain/chat/service/ChatService.java b/src/main/java/com/back/domain/chat/service/ChatService.java index b5a2dfa4..c5b12c5d 100644 --- a/src/main/java/com/back/domain/chat/service/ChatService.java +++ b/src/main/java/com/back/domain/chat/service/ChatService.java @@ -59,10 +59,9 @@ public ChatPageResponse getRoomChatHistory(Long roomId, int page, int size, Loca // before 파라미터가 있으면 해당 시점 이전 메시지만 조회 Page messagesPage; if (before != null) { - // TODO: before 조건 추가한 Repository 메서드 필요 - messagesPage = roomChatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable); + messagesPage = roomChatMessageRepository.findMessagesByRoomIdBefore(roomId, before, pageable); } else { - messagesPage = roomChatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable); + messagesPage = roomChatMessageRepository.findMessagesByRoomId(roomId, pageable); } Page dtoPage = messagesPage.map(this::convertToDto); 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 3809ccb1..c41acc4a 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java @@ -1,36 +1,9 @@ package com.back.domain.studyroom.repository; import com.back.domain.studyroom.entity.RoomChatMessage; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; -import java.util.List; - @Repository -public interface RoomChatMessageRepository extends JpaRepository { - - // 방별 페이징된 채팅 메시지 조회 (무한 스크롤용) - @Query("SELECT m FROM RoomChatMessage m " + - "WHERE m.room.id = :roomId " + - "ORDER BY m.createdAt DESC") - Page findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId, Pageable pageable); - - // 특정 타임스탬프 이후의 메시지 조회 (실시간 업데이트용) - @Query("SELECT m FROM RoomChatMessage m " + - "WHERE m.room.id = :roomId " + - "AND m.createdAt > :timestamp " + - "ORDER BY m.createdAt ASC") - List findByRoomIdAfterTimestamp(@Param("roomId") Long roomId, - @Param("timestamp") LocalDateTime timestamp); - - // 방별 최근 20개 메시지 조회 - List findTop20ByRoomIdOrderByCreatedAtDesc(Long roomId); - - // 방별 전체 메시지 수 조회 - long countByRoomId(Long roomId); -} \ No newline at end of file +public interface RoomChatMessageRepository extends JpaRepository, RoomChatMessageRepositoryCustom { +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java new file mode 100644 index 00000000..7b3817c0 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryCustom.java @@ -0,0 +1,28 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomChatMessage; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; + +public interface RoomChatMessageRepositoryCustom { + + /** + * 방별 페이징된 채팅 메시지 조회 (최신순) + * @param roomId 방 ID + * @param pageable 페이징 정보 + * @return 페이징된 채팅 메시지 목록 + */ + Page findMessagesByRoomId(Long roomId, Pageable pageable); + + /** + * 특정 시점 이전의 채팅 메시지 조회 (무한 스크롤용) + * @param roomId 방 ID + * @param before 기준 시점 + * @param pageable 페이징 정보 + * @return 기준 시점 이전의 메시지 목록 + */ + Page findMessagesByRoomIdBefore(Long roomId, LocalDateTime before, Pageable pageable); + +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java new file mode 100644 index 00000000..0859c0fa --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryImpl.java @@ -0,0 +1,83 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.QRoom; +import com.back.domain.studyroom.entity.QRoomChatMessage; +import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.user.entity.QUser; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + + + +@Repository +@RequiredArgsConstructor +public class RoomChatMessageRepositoryImpl implements RoomChatMessageRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + private final QRoomChatMessage message = QRoomChatMessage.roomChatMessage; + private final QRoom room = QRoom.room; + private final QUser user = QUser.user; + + @Override + public Page findMessagesByRoomId(Long roomId, Pageable pageable) { + + // 메시지 목록 조회 + List messages = queryFactory + .selectFrom(message) + .leftJoin(message.room, room).fetchJoin() // Room 정보 즉시 로딩 + .leftJoin(message.user, user).fetchJoin() // User 정보 즉시 로딩 + .where(message.room.id.eq(roomId)) + .orderBy(message.createdAt.desc()) // 최신순 정렬 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 + Long totalCount = queryFactory + .select(message.count()) + .from(message) + .where(message.room.id.eq(roomId)) + .fetchOne(); + + return new PageImpl<>(messages, pageable, totalCount != null ? totalCount : 0); + } + + @Override + public Page findMessagesByRoomIdBefore(Long roomId, LocalDateTime before, Pageable pageable) { + + // 조건부 WHERE 절 (before가 null이면 조건 제외) + BooleanExpression whereClause = message.room.id.eq(roomId); + if (before != null) { + whereClause = whereClause.and(message.createdAt.lt(before)); // before 시점 이전 + } + + List messages = queryFactory + .selectFrom(message) + .leftJoin(message.room, room).fetchJoin() + .leftJoin(message.user, user).fetchJoin() + .where(whereClause) + .orderBy(message.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 조건에 맞는 전체 개수 + Long totalCount = queryFactory + .select(message.count()) + .from(message) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(messages, pageable, totalCount != null ? totalCount : 0); + } + +} diff --git a/src/main/java/com/back/global/config/QueryDslConfig.java b/src/main/java/com/back/global/config/QueryDslConfig.java new file mode 100644 index 00000000..abfa3255 --- /dev/null +++ b/src/main/java/com/back/global/config/QueryDslConfig.java @@ -0,0 +1,25 @@ +package com.back.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * QueryDSL 설정 + * JPAQueryFactory 빈을 생성하여 QueryDSL 사용 가능하게 함 + */ +@Configuration +@RequiredArgsConstructor +public class QueryDslConfig { + + @PersistenceContext + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/config/WebSocketConfig.java similarity index 99% rename from src/main/java/com/back/global/websocket/config/WebSocketConfig.java rename to src/main/java/com/back/global/config/WebSocketConfig.java index d4b3f953..60341e3e 100644 --- a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/config/WebSocketConfig.java @@ -1,4 +1,4 @@ -package com.back.global.websocket.config; +package com.back.global.config; import com.back.global.security.CustomUserDetails; import com.back.global.security.JwtTokenProvider; From 81c353a67703698097cb30509d0de6d49ef7e69d Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Wed, 24 Sep 2025 16:45:08 +0900 Subject: [PATCH 4/6] =?UTF-8?q?Test:=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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatApiController.java | 8 +- .../domain/chat/dto/ChatPageResponse.java | 2 + .../back/domain/chat/service/ChatService.java | 21 +- .../back/domain/studyroom/entity/Room.java | 6 +- .../studyroom/entity/RoomChatMessage.java | 11 +- .../com/back/domain/user/entity/User.java | 6 +- .../com/back/global/entity/BaseEntity.java | 6 +- .../global/security/CustomUserDetails.java | 2 + .../controller/ChatApiControllerTest.java | 73 ++++ .../ChatWebSocketControllerTest.java | 262 ++++++++++++ .../domain/chat/service/ChatServiceTest.java | 397 ++++++++++++++++++ .../RoomChatMessageRepositoryTest.java | 239 +++++++++++ .../global/config/QueryDslTestConfig.java | 19 + 13 files changed, 1034 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/back/domain/chat/controller/ChatApiControllerTest.java create mode 100644 src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java create mode 100644 src/test/java/com/back/domain/chat/service/ChatServiceTest.java create mode 100644 src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java create mode 100644 src/test/java/com/back/global/config/QueryDslTestConfig.java diff --git a/src/main/java/com/back/domain/chat/controller/ChatApiController.java b/src/main/java/com/back/domain/chat/controller/ChatApiController.java index 2c3e0cb5..ff91f3cb 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatApiController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatApiController.java @@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; -import java.util.Map; @RestController @RequiredArgsConstructor @@ -26,15 +25,10 @@ public class ChatApiController { public ResponseEntity> getRoomChatMessages( @PathVariable Long roomId, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "50") int size, + @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime before, @AuthenticationPrincipal CustomUserDetails userDetails) { - // size 최대값 제한 (임시: max 100) - if (size > 100) { - size = 100; - } - ChatPageResponse chatHistory = chatService.getRoomChatHistory(roomId, page, size, before); return ResponseEntity diff --git a/src/main/java/com/back/domain/chat/dto/ChatPageResponse.java b/src/main/java/com/back/domain/chat/dto/ChatPageResponse.java index 989454ff..09285ac3 100644 --- a/src/main/java/com/back/domain/chat/dto/ChatPageResponse.java +++ b/src/main/java/com/back/domain/chat/dto/ChatPageResponse.java @@ -15,6 +15,7 @@ public class ChatPageResponse { private List content; private PageableDto pageable; + private long totalElements; // 페이징 정보 DTO @Data @@ -36,6 +37,7 @@ public static ChatPageResponse from(org.springframework.data.domain.Page new CustomException(ErrorCode.ROOM_NOT_FOUND)); - Pageable pageable = PageRequest.of(page, size); + // size 값 검증 및 제한 + int validatedSize = validateAndLimitPageSize(size); + + Pageable pageable = PageRequest.of(page, validatedSize); // before 파라미터가 있으면 해당 시점 이전 메시지만 조회 Page messagesPage; @@ -69,6 +78,14 @@ public ChatPageResponse getRoomChatHistory(Long roomId, int page, int size, Loca return ChatPageResponse.from(dtoPage); } + // size 값 검증 및 최대값 제한 + private int validateAndLimitPageSize(int size) { + if (size <= 0) { + return DEFAULT_PAGE_SIZE; // 0 이하면 기본값 사용 + } + return Math.min(size, MAX_PAGE_SIZE); // 최대값 제한 + } + // 메시지 엔티티를 DTO로 변환 private ChatMessageDto convertToDto(RoomChatMessage message) { return ChatMessageDto.builder() @@ -84,4 +101,4 @@ private ChatMessageDto convertToDto(RoomChatMessage message) { .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/studyroom/entity/Room.java b/src/main/java/com/back/domain/studyroom/entity/Room.java index e07fd5a6..cb3c81b3 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -4,15 +4,19 @@ import com.back.domain.user.entity.User; import com.back.global.entity.BaseEntity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; import java.util.ArrayList; import java.util.List; @Entity -@NoArgsConstructor @Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor public class Room extends BaseEntity { private String title; diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java b/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java index 0028f3b2..f4f0ea21 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java @@ -6,12 +6,17 @@ import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; @Entity @Getter +@SuperBuilder @NoArgsConstructor +@AllArgsConstructor public class RoomChatMessage extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "room_id") @@ -23,10 +28,4 @@ public class RoomChatMessage extends BaseEntity { private String content; - // 채팅 메세지 생성자 - public RoomChatMessage(Room room, User user, String content) { - this.room = room; - this.user = user; - this.content = content; - } } diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index 554aa564..4964b3e6 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -9,16 +9,20 @@ import com.back.domain.studyroom.entity.RoomParticipantHistory; import com.back.global.entity.BaseEntity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; import java.util.ArrayList; import java.util.List; @Entity @Getter -@Table(name = "USERS") +@SuperBuilder @NoArgsConstructor +@AllArgsConstructor +@Table(name = "USERS") public class User extends BaseEntity { private String username; diff --git a/src/main/java/com/back/global/entity/BaseEntity.java b/src/main/java/com/back/global/entity/BaseEntity.java index 772d8e1b..e266338c 100644 --- a/src/main/java/com/back/global/entity/BaseEntity.java +++ b/src/main/java/com/back/global/entity/BaseEntity.java @@ -2,15 +2,19 @@ import jakarta.persistence.*; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +@Getter +@SuperBuilder @MappedSuperclass +@NoArgsConstructor @EntityListeners(AuditingEntityListener.class) -@Getter public abstract class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/com/back/global/security/CustomUserDetails.java b/src/main/java/com/back/global/security/CustomUserDetails.java index eff19059..f3831321 100644 --- a/src/main/java/com/back/global/security/CustomUserDetails.java +++ b/src/main/java/com/back/global/security/CustomUserDetails.java @@ -1,6 +1,7 @@ package com.back.global.security; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -13,6 +14,7 @@ * - JWT에서 파싱한 사용자 정보를 담고 있음 */ @Getter +@Builder @AllArgsConstructor public class CustomUserDetails implements UserDetails { private Long userId; diff --git a/src/test/java/com/back/domain/chat/controller/ChatApiControllerTest.java b/src/test/java/com/back/domain/chat/controller/ChatApiControllerTest.java new file mode 100644 index 00000000..c6be136e --- /dev/null +++ b/src/test/java/com/back/domain/chat/controller/ChatApiControllerTest.java @@ -0,0 +1,73 @@ +package com.back.domain.chat.controller; + +import com.back.domain.chat.dto.ChatPageResponse; +import com.back.domain.chat.service.ChatService; +import com.back.global.security.JwtTokenProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; + +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; + +import java.util.List; + +@SpringBootTest +@AutoConfigureMockMvc +class ChatApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @MockitoBean + private ChatService chatService; + + @Test + @DisplayName("채팅 기록 조회 성공") + void t1() throws Exception { + ChatPageResponse mockResponse = ChatPageResponse.builder() + .content(List.of()) + .pageable(ChatPageResponse.PageableDto.builder() + .page(0) + .size(20) + .hasNext(false) + .build()) + .totalElements(0) + .build(); + + given(chatService.getRoomChatHistory(anyLong(), anyInt(), anyInt(), any())) + .willReturn(mockResponse); + + // JWT 관련 스텁 + given(jwtTokenProvider.validateToken(anyString())).willReturn(true); + given(jwtTokenProvider.getAuthentication(anyString())) + .willReturn(new UsernamePasswordAuthenticationToken( + "mockUser", null, List.of()) + ); + + mockMvc.perform(get("/api/rooms/1/messages") + .param("page", "0") + .param("size", "20") + .header("Authorization", "Bearer faketoken") // 가짜 토큰 넣기 + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.totalElements").value(0)); + } +} + diff --git a/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java b/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java new file mode 100644 index 00000000..802ecfb9 --- /dev/null +++ b/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java @@ -0,0 +1,262 @@ +package com.back.domain.chat.controller; + +import com.back.domain.chat.dto.ChatMessageDto; +import com.back.domain.chat.service.ChatService; +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.security.CustomUserDetails; +import com.back.global.websocket.dto.WebSocketErrorResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import java.lang.reflect.Field; +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatWebSocketController 테스트") +class ChatWebSocketControllerTest { + + @Mock + private ChatService chatService; + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @Mock + private SimpMessageHeaderAccessor headerAccessor; + + @InjectMocks + private ChatWebSocketController chatWebSocketController; + + private CustomUserDetails testUser; + private Principal testPrincipal; + private User mockUser; + private UserProfile mockUserProfile; + private RoomChatMessage mockSavedMessage; + + @BeforeEach + void setUp() throws Exception { + testUser = CustomUserDetails.builder() + .userId(1L) + .username("testuser") + .build(); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + testUser, null, testUser.getAuthorities() + ); + testPrincipal = authentication; + + // UserProfile 생성 + mockUserProfile = createUserProfile("테스터", "https://example.com/profile.jpg"); + + // Mock User 객체 생성 + mockUser = User.builder() + .id(1L) + .username("testuser") + .email("test@example.com") + .build(); + + // 리플렉션으로 userProfiles 필드 설정 + setUserProfiles(mockUser, Arrays.asList(mockUserProfile)); + + // Mock RoomChatMessage 생성 + mockSavedMessage = RoomChatMessage.builder() + .id(100L) + .user(mockUser) + .content("테스트 메시지") + .createdAt(LocalDateTime.now()) + .build(); + } + + // UserProfile 생성 헬퍼 메소드 + private UserProfile createUserProfile(String nickname, String profileImageUrl) throws Exception { + UserProfile userProfile = new UserProfile(); + + // 리플렉션으로 private 필드 설정 + setField(userProfile, "nickname", nickname); + setField(userProfile, "profileImageUrl", profileImageUrl); + setField(userProfile, "user", mockUser); + + return userProfile; + } + + // User의 userProfiles 필드 설정 + private void setUserProfiles(User user, List profiles) throws Exception { + Field userProfilesField = User.class.getDeclaredField("userProfiles"); + userProfilesField.setAccessible(true); + userProfilesField.set(user, profiles); + } + + // 리플렉션으로 필드 값 설정하는 헬퍼 메소드 + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + @Test + @DisplayName("정상적인 채팅 메시지 처리") + void t1() { + // Given + Long roomId = 1L; + ChatMessageDto inputMessage = ChatMessageDto.builder() + .content("테스트 메시지") + .messageType("TEXT") + .build(); + + // 실제로 필요한 stubbing만 설정 + given(chatService.saveRoomChatMessage(any(ChatMessageDto.class))).willReturn(mockSavedMessage); + + // When + chatWebSocketController.handleRoomChat(roomId, inputMessage, headerAccessor, testPrincipal); + + // Then + verify(chatService).saveRoomChatMessage(argThat(dto -> + dto.getRoomId().equals(roomId) && + dto.getUserId().equals(1L) && + dto.getContent().equals("테스트 메시지") + )); + + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId), + argThat((ChatMessageDto responseDto) -> + responseDto.getMessageId().equals(100L) && + responseDto.getRoomId().equals(roomId) && + responseDto.getUserId().equals(1L) && + responseDto.getNickname().equals("테스터") && + responseDto.getProfileImageUrl().equals("https://example.com/profile.jpg") && + responseDto.getContent().equals("테스트 메시지") && + responseDto.getMessageType().equals("TEXT") + ) + ); + + // 에러 메시지는 전송되지 않아야 함 + verify(messagingTemplate, never()).convertAndSendToUser(anyString(), anyString(), any()); + } + + @Test + @DisplayName("인증되지 않은 사용자의 메시지 처리 - 에러 전송") + void t2() { + Long roomId = 1L; + ChatMessageDto inputMessage = ChatMessageDto.builder() + .content("테스트 메시지") + .messageType("TEXT") + .build(); + + Principal invalidPrincipal = null; // 인증 정보 없음 + + // 에러 응답을 위해 sessionId가 필요 + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + + chatWebSocketController.handleRoomChat(roomId, inputMessage, headerAccessor, invalidPrincipal); + + verify(chatService, never()).saveRoomChatMessage(any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + + // 에러 메시지가 해당 사용자에게만 전송되는지 확인 + verify(messagingTemplate).convertAndSendToUser( + eq("test-session-123"), + eq("/queue/errors"), + argThat((WebSocketErrorResponse errorResponse) -> + errorResponse.getError().getCode().equals("WS_UNAUTHORIZED") && + errorResponse.getError().getMessage().equals("인증이 필요합니다") + ) + ); + } + + @Test + @DisplayName("서비스 계층 예외 발생 시 에러 처리") + void t3() { + Long roomId = 1L; + ChatMessageDto inputMessage = ChatMessageDto.builder() + .content("테스트 메시지") + .messageType("TEXT") + .build(); + + // 예외 발생 시 sessionId와 서비스 예외 설정 + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + given(chatService.saveRoomChatMessage(any())).willThrow(new RuntimeException("DB 오류")); + + chatWebSocketController.handleRoomChat(roomId, inputMessage, headerAccessor, testPrincipal); + + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + + verify(messagingTemplate).convertAndSendToUser( + eq("test-session-123"), + eq("/queue/errors"), + argThat((WebSocketErrorResponse errorResponse) -> + errorResponse.getError().getCode().equals("WS_ROOM_NOT_FOUND") && + errorResponse.getError().getMessage().equals("존재하지 않는 방입니다") + ) + ); + } + + @Test + @DisplayName("잘못된 Principal 타입 처리") + void t4() { + Long roomId = 1L; + ChatMessageDto inputMessage = ChatMessageDto.builder() + .content("테스트 메시지") + .messageType("TEXT") + .build(); + + // Authentication이 아닌 다른 Principal + Principal invalidTypePrincipal = () -> "some-principal-name"; + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + + chatWebSocketController.handleRoomChat(roomId, inputMessage, headerAccessor, invalidTypePrincipal); + + verify(chatService, never()).saveRoomChatMessage(any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + + verify(messagingTemplate).convertAndSendToUser( + eq("test-session-123"), + eq("/queue/errors"), + any(WebSocketErrorResponse.class) + ); + } + + @Test + @DisplayName("CustomUserDetails가 아닌 Principal 객체 처리") + void t5() { + Long roomId = 1L; + ChatMessageDto inputMessage = ChatMessageDto.builder() + .content("테스트 메시지") + .messageType("TEXT") + .build(); + + Authentication authWithWrongPrincipal = new UsernamePasswordAuthenticationToken( + "string-principal", null, null + ); + + given(headerAccessor.getSessionId()).willReturn("test-session-123"); + + chatWebSocketController.handleRoomChat(roomId, inputMessage, headerAccessor, authWithWrongPrincipal); + + verify(chatService, never()).saveRoomChatMessage(any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + + verify(messagingTemplate).convertAndSendToUser( + eq("test-session-123"), + eq("/queue/errors"), + any(WebSocketErrorResponse.class) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/domain/chat/service/ChatServiceTest.java b/src/test/java/com/back/domain/chat/service/ChatServiceTest.java new file mode 100644 index 00000000..29288a0a --- /dev/null +++ b/src/test/java/com/back/domain/chat/service/ChatServiceTest.java @@ -0,0 +1,397 @@ +package com.back.domain.chat.service; + +import com.back.domain.chat.dto.ChatMessageDto; +import com.back.domain.chat.dto.ChatPageResponse; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.studyroom.repository.RoomChatMessageRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +@DisplayName("ChatService 테스트") +class ChatServiceTest { + + @Mock + private RoomChatMessageRepository roomChatMessageRepository; + + @Mock + private RoomRepository roomRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ChatService chatService; + + private Room testRoom; + private User testUser; + private UserProfile testUserProfile; + private RoomChatMessage testMessage; + + @BeforeEach + void setUp() throws Exception { + // UserProfile 생성 + testUserProfile = createUserProfile("테스터", "https://example.com/profile.jpg"); + + // User 생성 및 userProfiles 필드 설정 + testUser = User.builder() + .id(1L) + .email("test@example.com") + .username("테스터") + .build(); + + // 리플렉션으로 userProfiles 필드 설정 + setUserProfiles(testUser, Arrays.asList(testUserProfile)); + + testRoom = Room.builder() + .id(1L) + .title("테스트 방") + .description("테스트용 스터디룸") + .build(); + + testMessage = RoomChatMessage.builder() + .id(1L) + .room(testRoom) + .user(testUser) + .content("테스트 메시지") + .createdAt(LocalDateTime.now()) + .build(); + } + + // UserProfile 생성 헬퍼 메소드 + private UserProfile createUserProfile(String nickname, String profileImageUrl) throws Exception { + UserProfile userProfile = new UserProfile(); + + // 리플렉션으로 private 필드 설정 + setField(userProfile, "nickname", nickname); + setField(userProfile, "profileImageUrl", profileImageUrl); + setField(userProfile, "user", testUser); + + return userProfile; + } + + // User의 userProfiles 필드 설정 + private void setUserProfiles(User user, List profiles) throws Exception { + Field userProfilesField = User.class.getDeclaredField("userProfiles"); + userProfilesField.setAccessible(true); + userProfilesField.set(user, profiles); + } + + // 리플렉션으로 필드 값 설정하는 헬퍼 메소드 + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + @Test + @DisplayName("채팅 메시지 저장 성공") + void t1() { + // Given + ChatMessageDto chatMessageDto = ChatMessageDto.builder() + .roomId(1L) + .userId(1L) + .content("안녕하세요!") + .build(); + + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomChatMessageRepository.save(any(RoomChatMessage.class))).willReturn(testMessage); + + // When + RoomChatMessage result = chatService.saveRoomChatMessage(chatMessageDto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getContent()).isEqualTo("테스트 메시지"); + + verify(roomRepository).findById(1L); + verify(userRepository).findById(1L); + verify(roomChatMessageRepository).save(any(RoomChatMessage.class)); + } + + @Test + @DisplayName("채팅 메시지 저장 실패 - 존재하지 않는 방") + void t2() { + ChatMessageDto chatMessageDto = ChatMessageDto.builder() + .roomId(999L) + .userId(1L) + .content("메시지") + .build(); + + given(roomRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> chatService.saveRoomChatMessage(chatMessageDto)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + + verify(roomRepository).findById(999L); + verify(userRepository, never()).findById(any()); + verify(roomChatMessageRepository, never()).save(any()); + } + + @Test + @DisplayName("채팅 메시지 저장 실패 - 존재하지 않는 사용자") + void t3() { + ChatMessageDto chatMessageDto = ChatMessageDto.builder() + .roomId(1L) + .userId(999L) + .content("메시지") + .build(); + + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> chatService.saveRoomChatMessage(chatMessageDto)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + + verify(roomRepository).findById(1L); + verify(userRepository).findById(999L); + verify(roomChatMessageRepository, never()).save(any()); + } + + @Test + @DisplayName("채팅 기록 조회 성공 - before 파라미터 없음") + void t4() { + Long roomId = 1L; + int page = 0; + int size = 10; + LocalDateTime before = null; + + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, PageRequest.of(page, size), 1); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomId(eq(roomId), any(Pageable.class))) + .willReturn(messagePage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, page, size, before); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + + ChatMessageDto messageDto = result.getContent().get(0); + assertThat(messageDto.getMessageId()).isEqualTo(1L); + assertThat(messageDto.getRoomId()).isEqualTo(1L); + assertThat(messageDto.getUserId()).isEqualTo(1L); + assertThat(messageDto.getNickname()).isEqualTo("테스터"); + assertThat(messageDto.getContent()).isEqualTo("테스트 메시지"); + assertThat(messageDto.getMessageType()).isEqualTo("TEXT"); + + verify(roomChatMessageRepository).findMessagesByRoomId(eq(roomId), any(Pageable.class)); + verify(roomChatMessageRepository, never()).findMessagesByRoomIdBefore(any(), any(), any()); + } + + @Test + @DisplayName("채팅 기록 조회 성공 - before 파라미터 있음") + void t5() { + Long roomId = 1L; + int page = 0; + int size = 10; + LocalDateTime before = LocalDateTime.now().minusHours(1); + + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, PageRequest.of(page, size), 1); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomIdBefore(eq(roomId), eq(before), any(Pageable.class))) + .willReturn(messagePage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, page, size, before); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + + ChatMessageDto messageDto = result.getContent().get(0); + assertThat(messageDto.getNickname()).isEqualTo("테스터"); + assertThat(messageDto.getProfileImageUrl()).isEqualTo("https://example.com/profile.jpg"); + + verify(roomChatMessageRepository).findMessagesByRoomIdBefore(eq(roomId), eq(before), any(Pageable.class)); + verify(roomChatMessageRepository, never()).findMessagesByRoomId(any(), any()); + } + + @Test + @DisplayName("채팅 기록 조회 실패 - 존재하지 않는 방") + void t6() { + Long nonExistentRoomId = 999L; + + given(roomRepository.findById(nonExistentRoomId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> chatService.getRoomChatHistory(nonExistentRoomId, 0, 10, null)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + + verify(roomRepository).findById(nonExistentRoomId); + verify(roomChatMessageRepository, never()).findMessagesByRoomId(any(), any()); + verify(roomChatMessageRepository, never()).findMessagesByRoomIdBefore(any(), any(), any()); + } + + @Test + @DisplayName("size 최대값 제한 테스트 - 100초과 요청") + void t7() { + Long roomId = 1L; + int page = 0; + int requestedSize = 150; + LocalDateTime before = null; + + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, PageRequest.of(page, 100), 1); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomId(eq(roomId), any(Pageable.class))) + .willReturn(messagePage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, page, requestedSize, before); + + assertThat(result).isNotNull(); + + // size가 100으로 제한되었는지 확인 + verify(roomChatMessageRepository).findMessagesByRoomId(eq(roomId), argThat(pageable -> + pageable.getPageSize() == 100 + )); + } + + @Test + @DisplayName("size 기본값 설정 테스트 - 0 요청") + void t8() { + Long roomId = 1L; + int page = 0; + int requestedSize = 0; + LocalDateTime before = null; + + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, PageRequest.of(page, 20), 1); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomId(eq(roomId), any(Pageable.class))) + .willReturn(messagePage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, page, requestedSize, before); + + assertThat(result).isNotNull(); + + // size가 기본값 20으로 설정되었는지 확인 + verify(roomChatMessageRepository).findMessagesByRoomId(eq(roomId), argThat(pageable -> + pageable.getPageSize() == 20 + )); + } + + @Test + @DisplayName("size 기본값 설정 테스트 - 음수 요청") + void t9() { + Long roomId = 1L; + int page = 0; + int requestedSize = -5; + LocalDateTime before = null; + + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, PageRequest.of(page, 20), 1); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomId(eq(roomId), any(Pageable.class))) + .willReturn(messagePage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, page, requestedSize, before); + + assertThat(result).isNotNull(); + + // size가 기본값 20으로 설정되었는지 확인 + verify(roomChatMessageRepository).findMessagesByRoomId(eq(roomId), argThat(pageable -> + pageable.getPageSize() == 20 + )); + } + + @Test + @DisplayName("size 정상 범위 테스트") + void t10() { + Long roomId = 1L; + int page = 0; + int requestedSize = 50; // 유효한 size + LocalDateTime before = null; + + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, PageRequest.of(page, 50), 1); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomId(eq(roomId), any(Pageable.class))) + .willReturn(messagePage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, page, requestedSize, before); + + assertThat(result).isNotNull(); + + // 요청한 size가 그대로 유지되는지 확인 + verify(roomChatMessageRepository).findMessagesByRoomId(eq(roomId), argThat(pageable -> + pageable.getPageSize() == 50 + )); + } + + @Test + @DisplayName("빈 채팅 기록 조회") + void t11() { + Long roomId = 1L; + Page emptyPage = new PageImpl<>(Arrays.asList(), PageRequest.of(0, 10), 0); + + given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); + given(roomChatMessageRepository.findMessagesByRoomId(eq(roomId), any(Pageable.class))) + .willReturn(emptyPage); + + ChatPageResponse result = chatService.getRoomChatHistory(roomId, 0, 10, null); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("convertToDto 메소드 테스트") + void t12() throws Exception { + // ChatService의 private 메소드에 접근하기 위해 리플렉션 사용 + java.lang.reflect.Method convertToDtoMethod = ChatService.class.getDeclaredMethod("convertToDto", RoomChatMessage.class); + convertToDtoMethod.setAccessible(true); + + ChatMessageDto result = (ChatMessageDto) convertToDtoMethod.invoke(chatService, testMessage); + + assertThat(result).isNotNull(); + assertThat(result.getMessageId()).isEqualTo(1L); + assertThat(result.getRoomId()).isEqualTo(1L); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getNickname()).isEqualTo("테스터"); + assertThat(result.getProfileImageUrl()).isEqualTo("https://example.com/profile.jpg"); + assertThat(result.getContent()).isEqualTo("테스트 메시지"); + assertThat(result.getMessageType()).isEqualTo("TEXT"); + assertThat(result.getAttachment()).isNull(); + assertThat(result.getCreatedAt()).isNotNull(); + } +} \ 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 new file mode 100644 index 00000000..ff5f4df1 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/repository/RoomChatMessageRepositoryTest.java @@ -0,0 +1,239 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.user.entity.User; +import com.back.global.config.QueryDslTestConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import({RoomChatMessageRepositoryImpl.class, QueryDslTestConfig.class}) +@DisplayName("RoomChatMessageRepository 테스트") +class RoomChatMessageRepositoryTest { + + @Autowired + private TestEntityManager testEntityManager; + + @Autowired + private RoomChatMessageRepository roomChatMessageRepository; + + private Room testRoom; + private User testUser1; + private User testUser2; + + @BeforeEach + void setUp() { + // 테스트용 사용자 생성 + testUser1 = User.builder() + .email("test1@example.com") + .username("테스터1") + .password("password") + .build(); + testEntityManager.persistAndFlush(testUser1); + + testUser2 = User.builder() + .email("test2@example.com") + .username("테스터2") + .password("password") + .build(); + testEntityManager.persistAndFlush(testUser2); + + // 테스트용 방 생성 + testRoom = Room.builder() + .title("테스트 스터디룸") + .description("QueryDSL 테스트용 방") + .maxParticipants(10) + .build(); + testEntityManager.persistAndFlush(testRoom); + + // 테스트용 채팅 메시지 생성 + createTestMessages(); + testEntityManager.flush(); + testEntityManager.clear(); + } + + private void createTestMessages() { + for (int i = 0; i < 10; i++) { + RoomChatMessage message = new RoomChatMessage( + testRoom, + i % 2 == 0 ? testUser1 : testUser2, + "테스트 메시지 " + (i + 1) + ); + + testEntityManager.persist(message); + // 각 메시지가 약간 다른 시간에 저장되도록 잠깐 대기 + try { + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Test + @DisplayName("방별 메시지 페이징 조회") + void t1() { + Pageable pageable = PageRequest.of(0, 5); + + Page result = roomChatMessageRepository.findMessagesByRoomId(testRoom.getId(), pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(5); + assertThat(result.getTotalElements()).isEqualTo(10); + assertThat(result.getTotalPages()).isEqualTo(2); + + // 최신순 정렬 확인 + RoomChatMessage firstMessage = result.getContent().get(0); + RoomChatMessage secondMessage = result.getContent().get(1); + assertThat(firstMessage.getCreatedAt()).isAfter(secondMessage.getCreatedAt()); + } + + @Test + @DisplayName("before 파라미터를 이용한 메시지 조회") + void t2() { + // 실제 저장된 메시지들의 시간을 기준으로 테스트 + List allMessages = roomChatMessageRepository.findAll(); + + // 중간 지점의 메시지 시간을 beforeTime으로 설정 + int midIndex = allMessages.size() / 2; + LocalDateTime beforeTime = allMessages.get(midIndex).getCreatedAt().plusNanos(1); + + Pageable pageable = PageRequest.of(0, 10); + + Page result = roomChatMessageRepository + .findMessagesByRoomIdBefore(testRoom.getId(), beforeTime, pageable); + + assertThat(result).isNotNull(); + + // beforeTime 이전의 메시지 개수 계산 + long expectedCount = allMessages.stream() + .filter(msg -> msg.getCreatedAt().isBefore(beforeTime) && msg.getRoom().getId().equals(testRoom.getId())) + .count(); + + assertThat(result.getContent()).hasSize((int) expectedCount); + assertThat(expectedCount).isGreaterThan(0); // 적어도 몇 개는 있어야 함 + + // 모든 메시지가 beforeTime 이전이고 해당 방의 메시지인지 확인 + result.getContent().forEach(message -> { + assertThat(message.getCreatedAt()).isBefore(beforeTime); + assertThat(message.getRoom().getId()).isEqualTo(testRoom.getId()); + }); + } + + @Test + @DisplayName("before 파라미터가 null일 때 정상 동작") + void t3() { + LocalDateTime before = null; + Pageable pageable = PageRequest.of(0, 10); + + Page result = roomChatMessageRepository + .findMessagesByRoomIdBefore(testRoom.getId(), before, pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(10); // before가 null이면 모든 메시지 조회 + assertThat(result.getTotalElements()).isEqualTo(10); + } + + @Test + @DisplayName("N+1 문제 해결 확인") + void t4() { + Pageable pageable = PageRequest.of(0, 3); + + Page result = roomChatMessageRepository.findMessagesByRoomId(testRoom.getId(), pageable); + + assertThat(result.getContent()).hasSize(3); + + for (RoomChatMessage message : result.getContent()) { + // 추가 쿼리 없이 접근 가능 + assertThat(message.getRoom().getTitle()).isNotNull(); + assertThat(message.getUser().getNickname()).isNotNull(); // username을 반환 + + // 연관 엔티티가 제대로 로드되었는지 확인 + assertThat(message.getRoom().getTitle()).isEqualTo("테스트 스터디룸"); + assertThat(message.getUser().getNickname()).isIn("테스터1", "테스터2"); + } + } + + @Test + @DisplayName("존재하지 않는 방 ID로 조회 시 빈 결과 반환") + void t5() { + long nonExistentRoomId = 99999L; + Pageable pageable = PageRequest.of(0, 10); + + Page result = roomChatMessageRepository + .findMessagesByRoomId(nonExistentRoomId, pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("특정 시점 이후 메시지 제외 - 경계값 테스트") + void t6() { + List allMessages = roomChatMessageRepository.findAll(); + + // 마지막에서 3번째 메시지 시간을 beforeTime으로 설정 + int targetIndex = allMessages.size() - 3; + LocalDateTime beforeTime = allMessages.get(targetIndex).getCreatedAt(); + + Pageable pageable = PageRequest.of(0, 10); + + Page result = roomChatMessageRepository + .findMessagesByRoomIdBefore(testRoom.getId(), beforeTime, pageable); + + assertThat(result).isNotNull(); + + // beforeTime 이전의 메시지들만 조회되어야 함 + result.getContent().forEach(message -> { + assertThat(message.getCreatedAt()).isBefore(beforeTime); + }); + + // 적어도 몇 개의 메시지는 조회되어야 함 (처음 몇 개는 beforeTime보다 이전이므로) + assertThat(result.getContent().size()).isGreaterThan(0); + } + + @Test + @DisplayName("아주 미래 시간 조건 - 모든 메시지 조회") + void t7() { + LocalDateTime futureTime = LocalDateTime.now().plusDays(1); + Pageable pageable = PageRequest.of(0, 10); + + Page result = roomChatMessageRepository + .findMessagesByRoomIdBefore(testRoom.getId(), futureTime, pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(10); // 모든 메시지가 미래 시간보다 이전이므로 모두 조회 + assertThat(result.getTotalElements()).isEqualTo(10); + } + + @Test + @DisplayName("아주 과거 시간 조건 - 빈 결과") + void t8() { + LocalDateTime pastTime = LocalDateTime.of(2020, 1, 1, 0, 0, 0); + Pageable pageable = PageRequest.of(0, 10); + + Page result = roomChatMessageRepository + .findMessagesByRoomIdBefore(testRoom.getId(), pastTime, pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); // 모든 메시지가 과거 시간보다 이후이므로 빈 결과 + assertThat(result.getTotalElements()).isEqualTo(0); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/config/QueryDslTestConfig.java b/src/test/java/com/back/global/config/QueryDslTestConfig.java new file mode 100644 index 00000000..cad4b66f --- /dev/null +++ b/src/test/java/com/back/global/config/QueryDslTestConfig.java @@ -0,0 +1,19 @@ +package com.back.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class QueryDslTestConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} From 5b25afe6e189a506172ecba4993358cad062eace Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Wed, 24 Sep 2025 16:53:27 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Docs:=20Swagger=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/domain/chat/controller/ChatApiController.java | 4 ++++ .../back/domain/chat/controller/ChatWebSocketController.java | 2 ++ .../global/websocket/controller/WebSocketTestController.java | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/src/main/java/com/back/domain/chat/controller/ChatApiController.java b/src/main/java/com/back/domain/chat/controller/ChatApiController.java index ff91f3cb..eff80066 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatApiController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatApiController.java @@ -4,6 +4,8 @@ import com.back.domain.chat.service.ChatService; import com.back.global.common.dto.RsData; import com.back.global.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; @@ -16,12 +18,14 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api") +@Tag(name = "Chat API", description = "채팅 메시지 조회 관련 API") public class ChatApiController { private final ChatService chatService; // 방 채팅 메시지 조회 (페이징, 특정 시간 이전 메시지) @GetMapping("/rooms/{roomId}/messages") + @Operation(summary = "채팅방 메시지 목록 조회", description = "특정 채팅방의 이전 메시지 기록을 페이징하여 조회합니다.") public ResponseEntity> getRoomChatMessages( @PathVariable Long roomId, @RequestParam(defaultValue = "0") int page, diff --git a/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java index 80ca49d2..29e4bc8b 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java @@ -5,6 +5,7 @@ import com.back.global.security.CustomUserDetails; import com.back.global.websocket.dto.WebSocketErrorResponse; import com.back.domain.chat.service.ChatService; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -17,6 +18,7 @@ @Controller @RequiredArgsConstructor +@Tag(name = "Chat WebSocket", description = "STOMP를 이용한 실시간 채팅 WebSocket 컨트롤러 (Swagger에서 직접 테스트 불가)") public class ChatWebSocketController { private final ChatService chatService; diff --git a/src/main/java/com/back/global/websocket/controller/WebSocketTestController.java b/src/main/java/com/back/global/websocket/controller/WebSocketTestController.java index 7b14b023..2131528f 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketTestController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketTestController.java @@ -1,6 +1,8 @@ package com.back.global.websocket.controller; import com.back.global.common.dto.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -15,10 +17,12 @@ @Slf4j @RestController @RequestMapping("/api/websocket") +@Tag(name = "WebSocket Test API", description = "WebSocket 서버 상태 확인 및 연결 정보 제공 API") public class WebSocketTestController { // WebSocket 기능 테스트용 REST 컨트롤러 // WebSocket 서버 상태 확인 @GetMapping("/health") + @Operation(summary = "WebSocket 서버 헬스체크", description = "WebSocket 서비스의 현재 상태를 확인합니다.") public ResponseEntity>> healthCheck() { log.info("WebSocket 헬스체크 요청"); @@ -40,6 +44,7 @@ public ResponseEntity>> healthCheck() { // WebSocket 연결 정보 제공 @GetMapping("/info") + @Operation(summary = "WebSocket 연결 정보 조회", description = "클라이언트가 WebSocket에 연결하기 위해 필요한 정보를 제공합니다.") public ResponseEntity>> getConnectionInfo() { log.info("WebSocket 연결 정보 요청"); From b9d9541f75c01c0c698598044810eaa9d4aa4f50 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Wed, 24 Sep 2025 17:55:02 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=B9=8C=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 문제 원인 - @SuperBuilder는 필드 초기값(ex. new ArrayList<>())을 무시하여, 빌더로 생성된 객체의 컬렉션 필드가 null이 되는 문제가 있었음. - 이로 인해 테스트 환경에서 NullPointerException 및 DB ConstraintViolationException (NOT NULL 제약 조건 위반)이 연쇄적으로 발생함. - 일부 테스트에서는 엔티티 필드명(userProfile)과 다른 이름(userProfiles)을 리플렉션으로 참조하여 NoSuchFieldException이 발생함. 2. 해결 방법 - @Builder.Default 적용 : Room, User 등 @SuperBuilder를 사용하는 모든 엔티티의 컬렉션 필드 및 초기값을 가진 필드에 @Builder.Default 어노테이션을 추가함. 이를 통해 빌더 사용 시에도 필드가 null이 아닌 기본값으로 안전하게 초기화되도록 보장함. - 테스트 코드 수정 : ChatWebSocketControllerTest에서 User의 userProfile 필드를 잘못된 이름("userProfiles")으로 참조하던 리플렉션 코드를 수정함. --- .../com/back/domain/studyroom/entity/Room.java | 13 +++++++++++++ .../java/com/back/domain/user/entity/User.java | 15 +++++++++++++++ .../controller/ChatWebSocketControllerTest.java | 10 +++++----- .../back/domain/chat/service/ChatServiceTest.java | 8 ++++---- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/back/domain/studyroom/entity/Room.java b/src/main/java/com/back/domain/studyroom/entity/Room.java index 67207bfb..56bb1955 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -5,6 +5,7 @@ import com.back.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; @@ -29,31 +30,43 @@ public class Room extends BaseEntity { private boolean allowScreenShare; // 방 상태 + @Builder.Default @Enumerated(EnumType.STRING) @Column(nullable = false) private RoomStatus status = RoomStatus.WAITING; + // 현재 참여자 + @Builder.Default @Column(nullable = false) private int currentParticipants = 0; + // 방장 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "created_by") private User createdBy; + // 테마 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "theme_id") private RoomTheme theme; // 연관관계 설정 + @Builder.Default @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) private List roomMembers = new ArrayList<>(); + // 채팅 메시지 + @Builder.Default @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) private List roomChatMessages = new ArrayList<>(); + // 참가자 기록 + @Builder.Default @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) private List roomParticipantHistories = new ArrayList<>(); + // 스터디 기록 + @Builder.Default @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) public List studyRecords = new ArrayList<>(); diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index ce19ef6f..ad56cf9f 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -10,6 +10,7 @@ import com.back.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; @@ -43,45 +44,59 @@ public class User extends BaseEntity { @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private UserProfile userProfile; + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List userTokens = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "fromUser", cascade = CascadeType.ALL, orphanRemoval = true) private List sentMessages = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "toUser", cascade = CascadeType.ALL, orphanRemoval = true) private List receivedMessages = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List roomMembers = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List roomChatMessages = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List roomParticipantHistories = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List studyPlans = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List todos = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List posts = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List postLikes = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List postBookmarks = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List comments = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List commentLikes = new ArrayList<>(); + @Builder.Default @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List fileAttachments = new ArrayList<>(); diff --git a/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java b/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java index 802ecfb9..a2295638 100644 --- a/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java +++ b/src/test/java/com/back/domain/chat/controller/ChatWebSocketControllerTest.java @@ -72,8 +72,8 @@ void setUp() throws Exception { .email("test@example.com") .build(); - // 리플렉션으로 userProfiles 필드 설정 - setUserProfiles(mockUser, Arrays.asList(mockUserProfile)); + // 리플렉션으로 userProfile 필드 설정 + setUserProfile(mockUser, mockUserProfile); // Mock RoomChatMessage 생성 mockSavedMessage = RoomChatMessage.builder() @@ -97,10 +97,10 @@ private UserProfile createUserProfile(String nickname, String profileImageUrl) t } // User의 userProfiles 필드 설정 - private void setUserProfiles(User user, List profiles) throws Exception { - Field userProfilesField = User.class.getDeclaredField("userProfiles"); + private void setUserProfile(User user, UserProfile profile) throws Exception { + Field userProfilesField = User.class.getDeclaredField("userProfile"); userProfilesField.setAccessible(true); - userProfilesField.set(user, profiles); + userProfilesField.set(user, profile); } // 리플렉션으로 필드 값 설정하는 헬퍼 메소드 diff --git a/src/test/java/com/back/domain/chat/service/ChatServiceTest.java b/src/test/java/com/back/domain/chat/service/ChatServiceTest.java index 29288a0a..06101285 100644 --- a/src/test/java/com/back/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/com/back/domain/chat/service/ChatServiceTest.java @@ -69,7 +69,7 @@ void setUp() throws Exception { .build(); // 리플렉션으로 userProfiles 필드 설정 - setUserProfiles(testUser, Arrays.asList(testUserProfile)); + setUserProfile(testUser, testUserProfile); testRoom = Room.builder() .id(1L) @@ -99,10 +99,10 @@ private UserProfile createUserProfile(String nickname, String profileImageUrl) t } // User의 userProfiles 필드 설정 - private void setUserProfiles(User user, List profiles) throws Exception { - Field userProfilesField = User.class.getDeclaredField("userProfiles"); + private void setUserProfile(User user, UserProfile profile) throws Exception { + Field userProfilesField = User.class.getDeclaredField("userProfile"); userProfilesField.setAccessible(true); - userProfilesField.set(user, profiles); + userProfilesField.set(user, profile); } // 리플렉션으로 필드 값 설정하는 헬퍼 메소드