diff --git a/src/main/java/com/back/global/websocket/event/SessionDisconnectedEvent.java b/src/main/java/com/back/global/websocket/event/SessionDisconnectedEvent.java new file mode 100644 index 00000000..0b1b7c5f --- /dev/null +++ b/src/main/java/com/back/global/websocket/event/SessionDisconnectedEvent.java @@ -0,0 +1,20 @@ +package com.back.global.websocket.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * WebSocket 세션 연결이 종료되었을 때 발생하는 내부 이벤트. + * 이 이벤트는 SessionManager가 발행하고, ParticipantService가 구독하여 처리합니다. + */ +@Getter +public class SessionDisconnectedEvent extends ApplicationEvent { + + private final Long userId; + + public SessionDisconnectedEvent(Object source, Long userId) { + super(source); + this.userId = userId; + } +} + diff --git a/src/main/java/com/back/global/websocket/event/UserJoinedEvent.java b/src/main/java/com/back/global/websocket/event/UserJoinedEvent.java new file mode 100644 index 00000000..7624721e --- /dev/null +++ b/src/main/java/com/back/global/websocket/event/UserJoinedEvent.java @@ -0,0 +1,14 @@ +package com.back.global.websocket.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserJoinedEvent { + private final String type = "USER_JOINED"; + private Long userId; + private String nickname; + private String profileImageUrl; + private Long avatarId; +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/event/UserLeftEvent.java b/src/main/java/com/back/global/websocket/event/UserLeftEvent.java new file mode 100644 index 00000000..60893437 --- /dev/null +++ b/src/main/java/com/back/global/websocket/event/UserLeftEvent.java @@ -0,0 +1,11 @@ +package com.back.global.websocket.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserLeftEvent { + private final String type = "USER_LEFT"; + private Long userId; +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java index b8ef6d64..cd0ce97e 100644 --- a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java +++ b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java @@ -1,13 +1,22 @@ package com.back.global.websocket.service; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.event.SessionDisconnectedEvent; +import com.back.global.websocket.event.UserJoinedEvent; +import com.back.global.websocket.event.UserLeftEvent; import com.back.global.websocket.store.RedisSessionStore; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Map; import java.util.Set; /** @@ -22,6 +31,17 @@ public class RoomParticipantService { private final RedisSessionStore redisSessionStore; + private final SimpMessagingTemplate messagingTemplate; + private final UserRepository userRepository; + + // 세션 종료 이벤트 리스너 + @EventListener + @Transactional + public void handleSessionDisconnected(SessionDisconnectedEvent event) { + Long userId = event.getUserId(); + log.info("[이벤트 수신] 세션 종료 이벤트 수신하여 퇴장 처리 시작 - 사용자: {}", userId); + exitAllRooms(userId); + } // 사용자 방 입장 (아바타 정보 포함) public void enterRoom(Long userId, Long roomId, Long avatarId) { @@ -32,7 +52,7 @@ public void enterRoom(Long userId, Long roomId, Long avatarId) { throw new CustomException(ErrorCode.WS_SESSION_NOT_FOUND); } - if (sessionInfo.currentRoomId() != null) { + if (sessionInfo.currentRoomId() != null && !sessionInfo.currentRoomId().equals(roomId)) { exitRoom(userId, sessionInfo.currentRoomId()); log.debug("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}", userId, sessionInfo.currentRoomId()); @@ -46,6 +66,8 @@ public void enterRoom(Long userId, Long roomId, Long avatarId) { saveUserAvatar(roomId, userId, avatarId); log.info("방 입장 완료 - 사용자: {}, 방: {}, 아바타: {}", userId, roomId, avatarId); + + broadcastUserJoined(roomId, userId, avatarId); } // 기존 메서드 호환성 유지 (아바타 없이 입장) @@ -56,16 +78,25 @@ public void enterRoom(Long userId, Long roomId) { // 사용자 방 퇴장 public void exitRoom(Long userId, Long roomId) { WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); - - if (sessionInfo == null) { - log.warn("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}", userId, roomId); - } else { + if (sessionInfo != null) { WebSocketSessionInfo updatedSession = sessionInfo.withoutRoom(); redisSessionStore.saveUserSession(userId, updatedSession); } - redisSessionStore.removeUserFromRoom(roomId, userId); log.info("방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId); + broadcastUserLeft(roomId, userId); + } + + public void exitAllRooms(Long userId) { + try { + Long currentRoomId = getCurrentRoomId(userId); + if (currentRoomId != null) { + exitRoom(userId, currentRoomId); + log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId); + } + } catch (Exception e) { + log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e); + } } // 사용자의 현재 방 ID 조회 @@ -90,29 +121,13 @@ public boolean isUserInRoom(Long userId, Long roomId) { return currentRoomId != null && currentRoomId.equals(roomId); } - // 모든 방에서 사용자 퇴장 처리 (세션 종료 시 사용) - public void exitAllRooms(Long userId) { - try { - Long currentRoomId = getCurrentRoomId(userId); - - if (currentRoomId != null) { - exitRoom(userId, currentRoomId); - log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId); - } - - } catch (Exception e) { - log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e); - // 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함) - } - } - /** * 여러 방의 온라인 참가자 수를 일괄 조회 * N+1 문제 해결을 위한 일괄 조회 메서드 * @param roomIds 방 ID 목록 * @return 방 ID → 참가자 수 맵 */ - public java.util.Map getParticipantCounts(java.util.List roomIds) { + public Map getParticipantCounts(java.util.List roomIds) { return redisSessionStore.getRoomUserCounts(roomIds); } @@ -125,18 +140,12 @@ public java.util.Map getParticipantCounts(java.util.List roomI * @param avatarId 아바타 ID */ private void saveUserAvatar(Long roomId, Long userId, Long avatarId) { - if (avatarId == null) { - return; // 아바타 정보가 없으면 저장하지 않음 - } - + if (avatarId == null) return; String avatarKey = buildAvatarKey(roomId, userId); - redisSessionStore.saveValue(avatarKey, avatarId.toString(), - java.time.Duration.ofMinutes(6)); - - log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}", - roomId, userId, avatarId); + redisSessionStore.saveValue(avatarKey, avatarId.toString(), java.time.Duration.ofMinutes(6)); + log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}", roomId, userId, avatarId); } - + /** * 사용자의 아바타 ID 조회 * @param roomId 방 ID @@ -154,21 +163,19 @@ public Long getUserAvatar(Long roomId, Long userId) { try { return Long.parseLong(avatarIdStr); } catch (NumberFormatException e) { - log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}", - roomId, userId, avatarIdStr); + log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}", roomId, userId, avatarIdStr); return null; } } - + /** * 여러 사용자의 아바타 ID 일괄 조회 (N+1 방지) * @param roomId 방 ID * @param userIds 사용자 ID 목록 * @return 사용자 ID → 아바타 ID 맵 */ - public java.util.Map getUserAvatars(Long roomId, Set userIds) { - java.util.Map result = new java.util.HashMap<>(); - + public Map getUserAvatars(Long roomId, Set userIds) { + Map result = new java.util.HashMap<>(); for (Long userId : userIds) { Long avatarId = getUserAvatar(roomId, userId); if (avatarId != null) { @@ -194,15 +201,28 @@ private String buildAvatarKey(Long roomId, Long userId) { * @param avatarId 새 아바타 ID */ public void updateUserAvatar(Long roomId, Long userId, Long avatarId) { - if (avatarId == null) { + if (avatarId == null) return; + String avatarKey = buildAvatarKey(roomId, userId); + redisSessionStore.saveValue(avatarKey, avatarId.toString(), java.time.Duration.ofMinutes(6)); + log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}", roomId, userId, avatarId); + } + + private void broadcastUserJoined(Long roomId, Long userId, Long avatarId) { + User user = userRepository.findById(userId).orElse(null); + if (user == null) { + log.error("📢 [방송 실패] 사용자 정보를 찾을 수 없어 입장 알림을 보낼 수 없습니다. userId: {}", userId); return; } - - String avatarKey = buildAvatarKey(roomId, userId); - redisSessionStore.saveValue(avatarKey, avatarId.toString(), - java.time.Duration.ofMinutes(6)); - - log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}", - roomId, userId, avatarId); + UserJoinedEvent event = new UserJoinedEvent(user.getId(), user.getNickname(), user.getProfileImageUrl(), avatarId); + String destination = "/topic/room/" + roomId + "/events"; + messagingTemplate.convertAndSend(destination, event); + log.info("📢 [방송] 사용자 입장 알림 - 방: {}, 사용자: {}", roomId, userId); + } + + private void broadcastUserLeft(Long roomId, Long userId) { + UserLeftEvent event = new UserLeftEvent(userId); + String destination = "/topic/room/" + roomId + "/events"; + messagingTemplate.convertAndSend(destination, event); + log.info("📢 [방송] 사용자 퇴장 알림 - 방: {}, 사용자: {}", roomId, userId); } } diff --git a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java index 602e93cf..81b24381 100644 --- a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java +++ b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java @@ -1,19 +1,19 @@ package com.back.global.websocket.service; import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.event.SessionDisconnectedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import java.util.Set; - @Slf4j @Service @RequiredArgsConstructor public class WebSocketSessionManager { private final UserSessionService userSessionService; - private final RoomParticipantService roomParticipantService; + private final ApplicationEventPublisher eventPublisher; // 사용자 세션 추가 (WebSocket 연결 시 호출) public void addSession(Long userId, String username, String sessionId) { @@ -25,10 +25,10 @@ public void removeSession(String sessionId) { Long userId = userSessionService.getUserIdBySessionId(sessionId); if (userId != null) { - // 1. 모든 방에서 퇴장 - roomParticipantService.exitAllRooms(userId); + // 세션 종료 이벤트 발행 + eventPublisher.publishEvent(new SessionDisconnectedEvent(this, userId)); - // 2. 세션 종료 + // 세션 종료 처리 userSessionService.terminateSession(sessionId); } else { log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId); @@ -54,43 +54,4 @@ public void updateLastActivity(Long userId) { public long getTotalOnlineUserCount() { return userSessionService.getTotalOnlineUserCount(); } - - // 사용자가 방에 입장 - public void joinRoom(Long userId, Long roomId) { - roomParticipantService.enterRoom(userId, roomId); - } - - // 사용자가 방에서 퇴장 - public void leaveRoom(Long userId, Long roomId) { - roomParticipantService.exitRoom(userId, roomId); - } - - // 방의 온라인 사용자 수 조회 - public long getRoomOnlineUserCount(Long roomId) { - return roomParticipantService.getParticipantCount(roomId); - } - - // 방의 온라인 사용자 목록 조회 - public Set getOnlineUsersInRoom(Long roomId) { - return roomParticipantService.getParticipants(roomId); - } - - // 특정 사용자의 현재 방 조회 - public Long getUserCurrentRoomId(Long userId) { - return roomParticipantService.getCurrentRoomId(userId); - } - - // 사용자가 특정 방에 참여 중인지 확인 - public boolean isUserInRoom(Long userId, Long roomId) { - return roomParticipantService.isUserInRoom(userId, roomId); - } - - // 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지) - public java.util.Map getBulkRoomOnlineUserCounts(java.util.List roomIds) { - return roomIds.stream() - .collect(java.util.stream.Collectors.toMap( - roomId -> roomId, - this::getRoomOnlineUserCount - )); - } } \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java b/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java index 5fb8b46e..18af7c7f 100644 --- a/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java +++ b/src/test/java/com/back/global/websocket/service/RoomParticipantServiceTest.java @@ -1,8 +1,13 @@ package com.back.global.websocket.service; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.event.SessionDisconnectedEvent; +import com.back.global.websocket.event.UserJoinedEvent; +import com.back.global.websocket.event.UserLeftEvent; import com.back.global.websocket.store.RedisSessionStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -11,16 +16,20 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; -import java.util.Set; +import java.util.Optional; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.never; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @DisplayName("RoomParticipantService 단위 테스트") @@ -29,29 +38,40 @@ class RoomParticipantServiceTest { @Mock private RedisSessionStore redisSessionStore; + @Mock + private SimpMessagingTemplate messagingTemplate; + + @Mock + private UserRepository userRepository; + + @Spy @InjectMocks private RoomParticipantService roomParticipantService; private Long userId; + private Long roomId; private String username; private String sessionId; - private Long roomId; private WebSocketSessionInfo sessionInfo; + private User testUser; @BeforeEach void setUp() { userId = 1L; + roomId = 100L; username = "testUser"; sessionId = "test-session-123"; - roomId = 100L; + // [FIX] 변경된 DTO의 정적 팩토리 메서드를 사용하여 객체 생성 sessionInfo = WebSocketSessionInfo.createNewSession(userId, username, sessionId); + testUser = User.builder().id(userId).username(username).build(); } @Test - @DisplayName("방 입장 - 정상 케이스 (첫 입장)") - void t1() { + @DisplayName("방 입장 - 정상 케이스 (첫 입장), 입장 이벤트 방송") + void enterRoom_FirstTime_BroadcastsUserJoined() { // given given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); // when roomParticipantService.enterRoom(userId, roomId); @@ -63,19 +83,22 @@ void t1() { WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); assertThat(updatedSession.currentRoomId()).isEqualTo(roomId); - assertThat(updatedSession.userId()).isEqualTo(userId); assertThat(updatedSession.username()).isEqualTo(username); + + // 방송 검증 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserJoinedEvent.class); + verify(messagingTemplate).convertAndSend(eq("/topic/room/" + roomId + "/events"), eventCaptor.capture()); + assertThat(eventCaptor.getValue().getUserId()).isEqualTo(userId); } @Test - @DisplayName("방 입장 - 기존 방에서 자동 퇴장 후 새 방 입장") - void t2() { + @DisplayName("방 입장 - 기존 방에서 자동 퇴장 후 새 방 입장, 퇴장/입장 이벤트 모두 방송") + void enterRoom_SwitchRoom_BroadcastsBothEvents() { // given Long oldRoomId = 200L; WebSocketSessionInfo sessionWithOldRoom = sessionInfo.withRoomId(oldRoomId); - given(redisSessionStore.getUserSession(userId)) - .willReturn(sessionWithOldRoom) // 첫 번째 호출 (입장 시) - .willReturn(sessionWithOldRoom); // 두 번째 호출 (퇴장 시) + given(redisSessionStore.getUserSession(anyLong())).willReturn(sessionWithOldRoom); + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); // when roomParticipantService.enterRoom(userId, roomId); @@ -83,36 +106,23 @@ void t2() { // then // 기존 방 퇴장 확인 verify(redisSessionStore).removeUserFromRoom(oldRoomId, userId); - // 새 방 입장 확인 verify(redisSessionStore).addUserToRoom(roomId, userId); - // 세션 업데이트 2번 (퇴장 시 1번, 입장 시 1번) - ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); - verify(redisSessionStore, times(2)).saveUserSession(eq(userId), sessionCaptor.capture()); - - WebSocketSessionInfo finalSession = sessionCaptor.getAllValues().get(1); - assertThat(finalSession.currentRoomId()).isEqualTo(roomId); - } - - @Test - @DisplayName("방 입장 - 세션 정보 없음 (예외 발생)") - void t3() { - // given - given(redisSessionStore.getUserSession(userId)).willReturn(null); - - // when & then - assertThatThrownBy(() -> roomParticipantService.enterRoom(userId, roomId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); + // 퇴장 방송 검증 + ArgumentCaptor leftEventCaptor = ArgumentCaptor.forClass(UserLeftEvent.class); + verify(messagingTemplate).convertAndSend(eq("/topic/room/" + oldRoomId + "/events"), leftEventCaptor.capture()); + assertThat(leftEventCaptor.getValue().getUserId()).isEqualTo(userId); - verify(redisSessionStore, never()).addUserToRoom(anyLong(), anyLong()); - verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); + // 입장 방송 검증 + ArgumentCaptor joinedEventCaptor = ArgumentCaptor.forClass(UserJoinedEvent.class); + verify(messagingTemplate).convertAndSend(eq("/topic/room/" + roomId + "/events"), joinedEventCaptor.capture()); + assertThat(joinedEventCaptor.getValue().getUserId()).isEqualTo(userId); } @Test - @DisplayName("방 퇴장 - 정상 케이스") - void t4() { + @DisplayName("방 퇴장 - 정상 케이스, 퇴장 이벤트 방송") + void exitRoom_Success_BroadcastsUserLeft() { // given WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); @@ -121,191 +131,48 @@ void t4() { roomParticipantService.exitRoom(userId, roomId); // then - ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); - verify(redisSessionStore).saveUserSession(eq(userId), sessionCaptor.capture()); verify(redisSessionStore).removeUserFromRoom(roomId, userId); - WebSocketSessionInfo updatedSession = sessionCaptor.getValue(); - assertThat(updatedSession.currentRoomId()).isNull(); - } - - @Test - @DisplayName("방 퇴장 - 세션 정보 없음 (퇴장 처리는 계속 진행)") - void t5() { - // given - given(redisSessionStore.getUserSession(userId)).willReturn(null); - - // when - roomParticipantService.exitRoom(userId, roomId); - - // then - verify(redisSessionStore).removeUserFromRoom(roomId, userId); - verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); - } - - @Test - @DisplayName("현재 방 ID 조회 - 방 있음") - void t6() { - // given - WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); - given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); - - // when - Long result = roomParticipantService.getCurrentRoomId(userId); - - // then - assertThat(result).isEqualTo(roomId); - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("현재 방 ID 조회 - 방 없음") - void t7() { - // given - given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); - - // when - Long result = roomParticipantService.getCurrentRoomId(userId); - - // then - assertThat(result).isNull(); - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("현재 방 ID 조회 - 세션 없음") - void t8() { - // given - given(redisSessionStore.getUserSession(userId)).willReturn(null); - - // when - Long result = roomParticipantService.getCurrentRoomId(userId); - - // then - assertThat(result).isNull(); - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("방의 참가자 목록 조회") - void t9() { - // given - Set expectedParticipants = Set.of(1L, 2L, 3L); - given(redisSessionStore.getRoomUsers(roomId)).willReturn(expectedParticipants); - - // when - Set result = roomParticipantService.getParticipants(roomId); - - // then - assertThat(result).containsExactlyInAnyOrderElementsOf(expectedParticipants); - verify(redisSessionStore).getRoomUsers(roomId); + // 방송 검증 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserLeftEvent.class); + verify(messagingTemplate).convertAndSend(eq("/topic/room/" + roomId + "/events"), eventCaptor.capture()); + assertThat(eventCaptor.getValue().getUserId()).isEqualTo(userId); } @Test - @DisplayName("방의 참가자 목록 조회 - 빈 방") - void t10() { + @DisplayName("세션 종료 이벤트 수신 - 정상적으로 모든 방에서 퇴장 처리") + void handleSessionDisconnected_ExitsAllRooms() { // given - given(redisSessionStore.getRoomUsers(roomId)).willReturn(Set.of()); + SessionDisconnectedEvent event = new SessionDisconnectedEvent(this, userId); + // exitAllRooms가 호출될 것을 기대하므로 doNothing() 처리 (Spy 객체이므로 실제 메서드 호출 방지) + doNothing().when(roomParticipantService).exitAllRooms(userId); // when - Set result = roomParticipantService.getParticipants(roomId); + roomParticipantService.handleSessionDisconnected(event); // then - assertThat(result).isEmpty(); - verify(redisSessionStore).getRoomUsers(roomId); + // exitAllRooms가 정확한 userId로 호출되었는지 검증 + verify(roomParticipantService, times(1)).exitAllRooms(userId); } @Test - @DisplayName("방의 참가자 수 조회") - void t11() { - // given - long expectedCount = 5L; - given(redisSessionStore.getRoomUserCount(roomId)).willReturn(expectedCount); - - // when - long result = roomParticipantService.getParticipantCount(roomId); - - // then - assertThat(result).isEqualTo(expectedCount); - verify(redisSessionStore).getRoomUserCount(roomId); - } - - @Test - @DisplayName("방의 참가자 수 조회 - 빈 방") - void t12() { - // given - given(redisSessionStore.getRoomUserCount(roomId)).willReturn(0L); - - // when - long result = roomParticipantService.getParticipantCount(roomId); - - // then - assertThat(result).isZero(); - verify(redisSessionStore).getRoomUserCount(roomId); - } - - @Test - @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 참여 중") - void t13() { - // given - WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); - given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); - - // when - boolean result = roomParticipantService.isUserInRoom(userId, roomId); - - // then - assertThat(result).isTrue(); - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 다른 방에 참여 중") - void t14() { - // given - Long differentRoomId = 999L; - WebSocketSessionInfo sessionWithDifferentRoom = sessionInfo.withRoomId(differentRoomId); - given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithDifferentRoom); - - // when - boolean result = roomParticipantService.isUserInRoom(userId, roomId); - - // then - assertThat(result).isFalse(); - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 어떤 방에도 없음") - void t15() { - // given - given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); - - // when - boolean result = roomParticipantService.isUserInRoom(userId, roomId); - - // then - assertThat(result).isFalse(); - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("사용자가 특정 방에 참여 중인지 확인 - 세션 없음") - void t16() { + @DisplayName("방 입장 - 세션 정보 없음 (예외 발생)") + void enterRoom_NoSession_ThrowsException() { // given given(redisSessionStore.getUserSession(userId)).willReturn(null); - // when - boolean result = roomParticipantService.isUserInRoom(userId, roomId); + // when & then + assertThatThrownBy(() -> roomParticipantService.enterRoom(userId, roomId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); - // then - assertThat(result).isFalse(); - verify(redisSessionStore).getUserSession(userId); + verify(redisSessionStore, never()).addUserToRoom(anyLong(), anyLong()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); } @Test @DisplayName("모든 방에서 퇴장 - 현재 방 있음") - void t17() { + void exitAllRooms_InRoom() { // given WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); @@ -314,14 +181,13 @@ void t17() { roomParticipantService.exitAllRooms(userId); // then - verify(redisSessionStore, times(2)).getUserSession(userId); - verify(redisSessionStore).removeUserFromRoom(roomId, userId); - verify(redisSessionStore).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); + // exitRoom 메서드가 내부적으로 호출되는지 검증 (Spy 객체 활용) + verify(roomParticipantService, times(1)).exitRoom(userId, roomId); } @Test @DisplayName("모든 방에서 퇴장 - 현재 방 없음") - void t18() { + void exitAllRooms_NotInRoom() { // given given(redisSessionStore.getUserSession(userId)).willReturn(sessionInfo); @@ -329,116 +195,7 @@ void t18() { roomParticipantService.exitAllRooms(userId); // then - verify(redisSessionStore).getUserSession(userId); - verify(redisSessionStore, never()).removeUserFromRoom(anyLong(), anyLong()); - verify(redisSessionStore, never()).saveUserSession(anyLong(), any()); - } - - @Test - @DisplayName("모든 방에서 퇴장 - 세션 없음") - void t19() { - // given - given(redisSessionStore.getUserSession(userId)).willReturn(null); - - // when - roomParticipantService.exitAllRooms(userId); - - // then - verify(redisSessionStore).getUserSession(userId); - verify(redisSessionStore, never()).removeUserFromRoom(anyLong(), anyLong()); - } - - @Test - @DisplayName("모든 방에서 퇴장 - 예외 발생해도 에러를 던지지 않음") - void t20() { - // given - given(redisSessionStore.getUserSession(userId)) - .willThrow(new RuntimeException("Redis connection failed")); - - // when & then - 예외가 발생해도 메서드는 정상 종료되어야 함 - assertThatCode(() -> roomParticipantService.exitAllRooms(userId)) - .doesNotThrowAnyException(); - - verify(redisSessionStore).getUserSession(userId); - } - - @Test - @DisplayName("같은 방에 재입장 시도") - void t21() { - // given - WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); - given(redisSessionStore.getUserSession(userId)).willReturn(sessionWithRoom); - - // when - roomParticipantService.enterRoom(userId, roomId); - - // then - // 같은 방이므로 퇴장 처리가 발생함 - verify(redisSessionStore).removeUserFromRoom(roomId, userId); - verify(redisSessionStore).addUserToRoom(roomId, userId); - - // 세션 업데이트는 2번 (퇴장 + 입장) - verify(redisSessionStore, times(2)).saveUserSession(eq(userId), any(WebSocketSessionInfo.class)); - } - - @Test - @DisplayName("방 A → 방 B → 방 C 연속 이동") - void t22() { - // given - Long roomA = 100L; - Long roomB = 200L; - Long roomC = 300L; - - WebSocketSessionInfo session1 = sessionInfo; - WebSocketSessionInfo sessionInA = session1.withRoomId(roomA); - WebSocketSessionInfo sessionInB = sessionInA.withRoomId(roomB); - - given(redisSessionStore.getUserSession(userId)) - .willReturn(session1) // 첫 번째 입장 (방 A) - .willReturn(sessionInA) // 두 번째 입장 (방 B) - 기존 방 A에서 퇴장 - .willReturn(sessionInA) // 방 A 퇴장 처리 - .willReturn(sessionInB) // 세 번째 입장 (방 C) - 기존 방 B에서 퇴장 - .willReturn(sessionInB); // 방 B 퇴장 처리 - - // when - roomParticipantService.enterRoom(userId, roomA); - roomParticipantService.enterRoom(userId, roomB); - roomParticipantService.enterRoom(userId, roomC); - - // then - verify(redisSessionStore).addUserToRoom(roomA, userId); - verify(redisSessionStore).addUserToRoom(roomB, userId); - verify(redisSessionStore).addUserToRoom(roomC, userId); - - verify(redisSessionStore).removeUserFromRoom(roomA, userId); - verify(redisSessionStore).removeUserFromRoom(roomB, userId); - } - - @Test - @DisplayName("방 입장 후 명시적 퇴장") - void t23() { - // given - WebSocketSessionInfo sessionWithoutRoom = sessionInfo; - WebSocketSessionInfo sessionWithRoom = sessionInfo.withRoomId(roomId); - - given(redisSessionStore.getUserSession(userId)) - .willReturn(sessionWithoutRoom) // 입장 시 - .willReturn(sessionWithRoom); // 퇴장 시 - - // when - roomParticipantService.enterRoom(userId, roomId); - roomParticipantService.exitRoom(userId, roomId); - - // then - verify(redisSessionStore).addUserToRoom(roomId, userId); - verify(redisSessionStore).removeUserFromRoom(roomId, userId); - - ArgumentCaptor captor = ArgumentCaptor.forClass(WebSocketSessionInfo.class); - verify(redisSessionStore, times(2)).saveUserSession(eq(userId), captor.capture()); - - // 입장 시 방 ID가 설정됨 - assertThat(captor.getAllValues().get(0).currentRoomId()).isEqualTo(roomId); - // 퇴장 시 방 ID가 null이 됨 - assertThat(captor.getAllValues().get(1).currentRoomId()).isNull(); + // exitRoom 메서드가 호출되지 않았는지 검증 + verify(roomParticipantService, never()).exitRoom(anyLong(), anyLong()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java index 06967b60..8746105c 100644 --- a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java +++ b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java @@ -1,24 +1,22 @@ package com.back.global.websocket.service; -import com.back.global.exception.CustomException; -import com.back.global.exception.ErrorCode; import com.back.global.websocket.dto.WebSocketSessionInfo; +import com.back.global.websocket.event.SessionDisconnectedEvent; 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.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; -import java.util.Set; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.never; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @DisplayName("WebSocketSessionManager 단위 테스트") @@ -28,7 +26,7 @@ class WebSocketSessionManagerTest { private UserSessionService userSessionService; @Mock - private RoomParticipantService roomParticipantService; + private ApplicationEventPublisher eventPublisher; @InjectMocks private WebSocketSessionManager sessionManager; @@ -36,19 +34,17 @@ class WebSocketSessionManagerTest { private Long userId; private String username; private String sessionId; - private Long roomId; @BeforeEach void setUp() { userId = 1L; username = "testuser"; sessionId = "test-session-123"; - roomId = 100L; } @Test - @DisplayName("세션 추가") - void addSession() { + @DisplayName("세션 추가 - UserSessionService의 registerSession 호출") + void addSession_CallsRegisterSession() { // when sessionManager.addSession(userId, username, sessionId); @@ -57,23 +53,28 @@ void addSession() { } @Test - @DisplayName("세션 제거 - 정상 케이스") - void removeSession_Success() { + @DisplayName("세션 제거 - 정상 케이스, SessionDisconnectedEvent 발행") + void removeSession_Success_PublishesEvent() { // given given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SessionDisconnectedEvent.class); // when sessionManager.removeSession(sessionId); // then - verify(userSessionService).getUserIdBySessionId(sessionId); - verify(roomParticipantService).exitAllRooms(userId); + // 1. 이벤트 발행 검증 + verify(eventPublisher).publishEvent(eventCaptor.capture()); + SessionDisconnectedEvent publishedEvent = eventCaptor.getValue(); + assertThat(publishedEvent.getUserId()).isEqualTo(userId); + + // 2. 세션 종료 처리 검증 verify(userSessionService).terminateSession(sessionId); } @Test - @DisplayName("세션 제거 - 존재하지 않는 세션") - void removeSession_NotFound() { + @DisplayName("세션 제거 - 존재하지 않는 세션, 이벤트 발행 안함") + void removeSession_NotFound_DoesNotPublishEvent() { // given given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(null); @@ -82,7 +83,8 @@ void removeSession_NotFound() { // then verify(userSessionService).getUserIdBySessionId(sessionId); - verify(roomParticipantService, never()).exitAllRooms(anyLong()); + // 이벤트가 발행되지 않았는지 검증 + verify(eventPublisher, never()).publishEvent(any()); verify(userSessionService, never()).terminateSession(anyString()); } @@ -104,7 +106,7 @@ void isUserConnected() { @DisplayName("사용자 세션 정보 조회") void getSessionInfo() { // given - // 생성자 변경 + // [FIX] 변경된 DTO의 정적 팩토리 메서드를 사용하여 객체 생성 WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.createNewSession(userId, username, sessionId); given(userSessionService.getSessionInfo(userId)).willReturn(sessionInfo); @@ -142,393 +144,4 @@ void getTotalOnlineUserCount() { assertThat(result).isEqualTo(expectedCount); verify(userSessionService).getTotalOnlineUserCount(); } - - @Test - @DisplayName("방 입장") - void joinRoom() { - // when - sessionManager.joinRoom(userId, roomId); - - // then - verify(roomParticipantService).enterRoom(userId, roomId); - } - - @Test - @DisplayName("방 퇴장") - void leaveRoom() { - // when - sessionManager.leaveRoom(userId, roomId); - - // then - verify(roomParticipantService).exitRoom(userId, roomId); - } - - @Test - @DisplayName("방의 온라인 사용자 수 조회") - void getRoomOnlineUserCount() { - // given - long expectedCount = 10L; - given(roomParticipantService.getParticipantCount(roomId)).willReturn(expectedCount); - - // when - long result = sessionManager.getRoomOnlineUserCount(roomId); - - // then - assertThat(result).isEqualTo(expectedCount); - verify(roomParticipantService).getParticipantCount(roomId); - } - - @Test - @DisplayName("방의 온라인 사용자 목록 조회") - void getOnlineUsersInRoom() { - // given - Set expectedUsers = Set.of(1L, 2L, 3L); - given(roomParticipantService.getParticipants(roomId)).willReturn(expectedUsers); - - // when - Set result = sessionManager.getOnlineUsersInRoom(roomId); - - // then - assertThat(result).containsExactlyInAnyOrderElementsOf(expectedUsers); - verify(roomParticipantService).getParticipants(roomId); - } - - @Test - @DisplayName("사용자의 현재 방 조회") - void getUserCurrentRoomId() { - // given - given(roomParticipantService.getCurrentRoomId(userId)).willReturn(roomId); - - // when - Long result = sessionManager.getUserCurrentRoomId(userId); - - // then - assertThat(result).isEqualTo(roomId); - verify(roomParticipantService).getCurrentRoomId(userId); - } - - @Test - @DisplayName("사용자가 특정 방에 참여 중인지 확인") - void isUserInRoom() { - // given - given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(true); - - // when - boolean result = sessionManager.isUserInRoom(userId, roomId); - - // then - assertThat(result).isTrue(); - verify(roomParticipantService).isUserInRoom(userId, roomId); - } - - @Test - @DisplayName("전체 플로우: 연결 → 방 입장 → Heartbeat → 방 퇴장 → 연결 종료") - void fullLifecycleFlow() { - // given - given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - - // when & then - // 1. 연결 - sessionManager.addSession(userId, username, sessionId); - verify(userSessionService).registerSession(userId, username, sessionId); - - // 2. 방 입장 - sessionManager.joinRoom(userId, roomId); - verify(roomParticipantService).enterRoom(userId, roomId); - - // 3. Heartbeat - sessionManager.updateLastActivity(userId); - verify(userSessionService).processHeartbeat(userId); - - // 4. 방 퇴장 - sessionManager.leaveRoom(userId, roomId); - verify(roomParticipantService).exitRoom(userId, roomId); - - // 5. 연결 종료 - sessionManager.removeSession(sessionId); - verify(roomParticipantService).exitAllRooms(userId); - verify(userSessionService).terminateSession(sessionId); - } - - @Test - @DisplayName("전체 플로우: 연결 → 방 A 입장 → 방 B 이동 → 연결 종료") - void fullLifecycleFlow_RoomTransition() { - // given - Long roomA = 100L; - Long roomB = 200L; - given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - - // when & then - // 1. 연결 - sessionManager.addSession(userId, username, sessionId); - verify(userSessionService).registerSession(userId, username, sessionId); - - // 2. 방 A 입장 - sessionManager.joinRoom(userId, roomA); - verify(roomParticipantService).enterRoom(userId, roomA); - - // 3. 방 B로 이동 (자동으로 방 A 퇴장) - sessionManager.joinRoom(userId, roomB); - verify(roomParticipantService).enterRoom(userId, roomB); - - // 4. 연결 종료 (모든 방에서 퇴장) - sessionManager.removeSession(sessionId); - verify(roomParticipantService).exitAllRooms(userId); - verify(userSessionService).terminateSession(sessionId); - } - - @Test - @DisplayName("여러 사용자의 동시 세션 관리") - void multipleUsersSessions() { - // given - Long userId1 = 1L; - Long userId2 = 2L; - Long userId3 = 3L; - - String username1 = "user1"; - String username2 = "user2"; - String username3 = "user3"; - - String sessionId1 = "session-1"; - String sessionId2 = "session-2"; - String sessionId3 = "session-3"; - - // when - sessionManager.addSession(userId1, username1, sessionId1); - sessionManager.addSession(userId2, username2, sessionId2); - sessionManager.addSession(userId3, username3, sessionId3); - - // then - verify(userSessionService).registerSession(userId1, username1, sessionId1); - verify(userSessionService).registerSession(userId2, username2, sessionId2); - verify(userSessionService).registerSession(userId3, username3, sessionId3); - } - - @Test - @DisplayName("여러 사용자가 같은 방에 입장") - void multipleUsersInSameRoom() { - // given - Long userId1 = 1L; - Long userId2 = 2L; - Long userId3 = 3L; - - // when - sessionManager.joinRoom(userId1, roomId); - sessionManager.joinRoom(userId2, roomId); - sessionManager.joinRoom(userId3, roomId); - - // then - verify(roomParticipantService).enterRoom(userId1, roomId); - verify(roomParticipantService).enterRoom(userId2, roomId); - verify(roomParticipantService).enterRoom(userId3, roomId); - } - - @Test - @DisplayName("중복 연결 시도 (기존 세션 종료 후 새 세션 등록)") - void duplicateConnection() { - // given - String newSessionId = "new-session-456"; - - // when - sessionManager.addSession(userId, username, sessionId); - sessionManager.addSession(userId, username, newSessionId); - - // then - verify(userSessionService).registerSession(userId, username, sessionId); - verify(userSessionService).registerSession(userId, username, newSessionId); - // UserSessionService 내부에서 기존 세션 종료 처리 - } - - @Test - @DisplayName("비정상 종료 시나리오: 명시적 퇴장 없이 연결 종료") - void abnormalDisconnection() { - // given - given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - - // when - sessionManager.addSession(userId, username, sessionId); - sessionManager.joinRoom(userId, roomId); - // 명시적 leaveRoom 없이 바로 연결 종료 - sessionManager.removeSession(sessionId); - - // then - verify(roomParticipantService).enterRoom(userId, roomId); - verify(roomParticipantService).exitAllRooms(userId); // 모든 방에서 자동 퇴장 - verify(userSessionService).terminateSession(sessionId); - } - - @Test - @DisplayName("방 입장 전 상태 조회") - void queryBeforeJoinRoom() { - // given - given(roomParticipantService.getCurrentRoomId(userId)).willReturn(null); - given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(false); - - // when - Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); - boolean isInRoom = sessionManager.isUserInRoom(userId, roomId); - - // then - assertThat(currentRoomId).isNull(); - assertThat(isInRoom).isFalse(); - verify(roomParticipantService).getCurrentRoomId(userId); - verify(roomParticipantService).isUserInRoom(userId, roomId); - } - - @Test - @DisplayName("방 입장 후 상태 조회") - void queryAfterJoinRoom() { - // given - given(roomParticipantService.getCurrentRoomId(userId)).willReturn(roomId); - given(roomParticipantService.isUserInRoom(userId, roomId)).willReturn(true); - - // when - sessionManager.joinRoom(userId, roomId); - Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); - boolean isInRoom = sessionManager.isUserInRoom(userId, roomId); - - // then - assertThat(currentRoomId).isEqualTo(roomId); - assertThat(isInRoom).isTrue(); - verify(roomParticipantService).enterRoom(userId, roomId); - verify(roomParticipantService).getCurrentRoomId(userId); - verify(roomParticipantService).isUserInRoom(userId, roomId); - } - - @Test - @DisplayName("Heartbeat 여러 번 호출") - void multipleHeartbeats() { - // when - sessionManager.updateLastActivity(userId); - sessionManager.updateLastActivity(userId); - sessionManager.updateLastActivity(userId); - - // then - verify(userSessionService, times(3)).processHeartbeat(userId); - } - - @Test - @DisplayName("빈 방의 사용자 목록 조회") - void getOnlineUsersInRoom_EmptyRoom() { - // given - given(roomParticipantService.getParticipants(roomId)).willReturn(Set.of()); - - // when - Set result = sessionManager.getOnlineUsersInRoom(roomId); - - // then - assertThat(result).isEmpty(); - verify(roomParticipantService).getParticipants(roomId); - } - - @Test - @DisplayName("온라인 사용자가 없을 때 전체 수 조회") - void getTotalOnlineUserCount_NoUsers() { - // given - given(userSessionService.getTotalOnlineUserCount()).willReturn(0L); - - // when - long result = sessionManager.getTotalOnlineUserCount(); - - // then - assertThat(result).isZero(); - verify(userSessionService).getTotalOnlineUserCount(); - } - - @Test - @DisplayName("세션 제거 시 모든 정리 작업이 순서대로 실행됨") - void removeSession_VerifyExecutionOrder() { - // given - given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - - // when - sessionManager.removeSession(sessionId); - - // then - // InOrder를 사용하여 실행 순서 검증 - var inOrder = inOrder(userSessionService, roomParticipantService); - inOrder.verify(userSessionService).getUserIdBySessionId(sessionId); - inOrder.verify(roomParticipantService).exitAllRooms(userId); - inOrder.verify(userSessionService).terminateSession(sessionId); - } - - @Test - @DisplayName("방 입장 실패 시 예외 전파") - void joinRoom_ExceptionPropagation() { - // given - willThrow(new CustomException(ErrorCode.WS_SESSION_NOT_FOUND)) - .given(roomParticipantService).enterRoom(userId, roomId); - - // when & then - assertThatThrownBy(() -> sessionManager.joinRoom(userId, roomId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WS_SESSION_NOT_FOUND); - - verify(roomParticipantService).enterRoom(userId, roomId); - } - - @Test - @DisplayName("통합 시나리오: 사용자 A와 B가 같은 방에서 만남") - void integrationScenario_TwoUsersInSameRoom() { - // given - Long userA = 1L; - Long userB = 2L; - - String usernameA = "userA"; - String usernameB = "userB"; - - String sessionA = "session-A"; - String sessionB = "session-B"; - - given(roomParticipantService.getParticipants(roomId)) - .willReturn(Set.of(userA)) - .willReturn(Set.of(userA, userB)); - - // when & then - // 1. 사용자 A 연결 및 방 입장 - sessionManager.addSession(userA, usernameA, sessionA); - sessionManager.joinRoom(userA, roomId); - - Set usersAfterA = sessionManager.getOnlineUsersInRoom(roomId); - assertThat(usersAfterA).containsExactly(userA); - - // 2. 사용자 B 연결 및 같은 방 입장 - sessionManager.addSession(userB, usernameB, sessionB); - sessionManager.joinRoom(userB, roomId); - - Set usersAfterB = sessionManager.getOnlineUsersInRoom(roomId); - assertThat(usersAfterB).containsExactlyInAnyOrder(userA, userB); - - // 3. 검증 - verify(userSessionService).registerSession(userA, usernameA, sessionA); - verify(userSessionService).registerSession(userB, usernameB, sessionB); - verify(roomParticipantService).enterRoom(userA, roomId); - verify(roomParticipantService).enterRoom(userB, roomId); - verify(roomParticipantService, times(2)).getParticipants(roomId); - } - - @Test - @DisplayName("통합 시나리오: 네트워크 불안정으로 재연결") - void integrationScenario_Reconnection() { - // given - String newSessionId = "new-session-789"; - given(userSessionService.getUserIdBySessionId(sessionId)).willReturn(userId); - - // when & then - // 1. 초기 연결 및 방 입장 - sessionManager.addSession(userId, username, sessionId); - sessionManager.joinRoom(userId, roomId); - - // 2. 갑작스런 연결 끊김 - sessionManager.removeSession(sessionId); - verify(roomParticipantService).exitAllRooms(userId); - - // 3. 재연결 (새 세션 ID로) - sessionManager.addSession(userId, username, newSessionId); - verify(userSessionService).registerSession(userId, username, newSessionId); - - // 4. 다시 방 입장 - sessionManager.joinRoom(userId, roomId); - verify(roomParticipantService, times(2)).enterRoom(userId, roomId); - } -} \ No newline at end of file +}