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..eff80066 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatApiController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatApiController.java @@ -3,37 +3,35 @@ 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 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; 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") +@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, - @RequestParam(defaultValue = "50") int size, + @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime before, - @RequestHeader("Authorization") String authorization) { - - // size 최대값 제한 (임시: max 100) - if (size > 100) { - size = 100; - } - - // TODO: JWT 토큰에서 사용자 정보 추출 및 권한 확인 + @AuthenticationPrincipal CustomUserDetails userDetails) { ChatPageResponse chatHistory = chatService.getRoomChatHistory(roomId, page, size, before); @@ -42,28 +40,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..29e4bc8b 100644 --- a/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java @@ -2,17 +2,23 @@ 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 io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.DestinationVariable; 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 +@Tag(name = "Chat WebSocket", description = "STOMP를 이용한 실시간 채팅 WebSocket 컨트롤러 (Swagger에서 직접 테스트 불가)") public class ChatWebSocketController { private final ChatService chatService; @@ -25,18 +31,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); @@ -74,4 +86,22 @@ public void handleRoomChat(@DestinationVariable Long roomId, messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse); } } + + // 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/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; 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); @@ -70,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() @@ -85,24 +101,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 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 0514a8a1..56bb1955 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,20 @@ import com.back.domain.user.entity.User; 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; import java.util.ArrayList; import java.util.List; @Entity -@NoArgsConstructor @Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor public class Room extends BaseEntity { private String title; private String description; @@ -25,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/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/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/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index 831fb3a3..ad56cf9f 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,21 @@ import com.back.domain.studyroom.entity.RoomParticipantHistory; 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; 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; @@ -39,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/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/config/WebSocketConfig.java b/src/main/java/com/back/global/config/WebSocketConfig.java new file mode 100644 index 00000000..60341e3e --- /dev/null +++ b/src/main/java/com/back/global/config/WebSocketConfig.java @@ -0,0 +1,134 @@ +package com.back.global.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 브로드캐스트 (방 채팅) + * - /queue: 1:1 메시지 (개인 DM) + * - /app: 클라이언트에서 서버로 메시지 전송 시 prefix + */ + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } + + /** + * STOMP 엔드포인트 등록 + * 클라이언트가 WebSocket 연결을 위해 사용할 엔드포인트 + */ + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .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()); + } + } +} 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/main/java/com/back/global/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java deleted file mode 100644 index 9f9f9d60..00000000 --- a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.back.global.websocket.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.messaging.simp.config.MessageBrokerRegistry; -import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; -import org.springframework.web.socket.config.annotation.StompEndpointRegistry; -import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; - -@Configuration -@EnableWebSocketMessageBroker -public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - /** - * 메시지 브로커 설정 - * - /topic: 1:N 브로드캐스트 (방 채팅) - * - /queue: 1:1 메시지 (개인 DM) - * - /app: 클라이언트에서 서버로 메시지 전송 시 prefix - */ - @Override - public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/topic", "/queue"); - config.setApplicationDestinationPrefixes("/app"); - config.setUserDestinationPrefix("/user"); - } - - /** - * STOMP 엔드포인트 등록 - * 클라이언트가 WebSocket 연결을 위해 사용할 엔드포인트 - */ - @Override - public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*") // 모든 도메인 허용 (개발용) - .withSockJS(); // SockJS 사용 - } -} 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 연결 정보 요청"); 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..a2295638 --- /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(); + + // 리플렉션으로 userProfile 필드 설정 + setUserProfile(mockUser, 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 setUserProfile(User user, UserProfile profile) throws Exception { + Field userProfilesField = User.class.getDeclaredField("userProfile"); + userProfilesField.setAccessible(true); + userProfilesField.set(user, profile); + } + + // 리플렉션으로 필드 값 설정하는 헬퍼 메소드 + 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..06101285 --- /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 필드 설정 + setUserProfile(testUser, 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 setUserProfile(User user, UserProfile profile) throws Exception { + Field userProfilesField = User.class.getDeclaredField("userProfile"); + userProfilesField.setAccessible(true); + userProfilesField.set(user, profile); + } + + // 리플렉션으로 필드 값 설정하는 헬퍼 메소드 + 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); + } +}