diff --git a/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java b/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java index bde4453a..e8e08593 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketApiController.java @@ -14,8 +14,8 @@ import java.util.Map; @RestController -@RequestMapping("api/ws") @RequiredArgsConstructor +@RequestMapping("api/ws") @Tag(name = "WebSocket REST API", description = "WebSocket 서버 상태 확인 + 실시간 연결 정보 제공 API") public class WebSocketApiController { diff --git a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java index ed085425..413fde00 100644 --- a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java +++ b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java @@ -15,8 +15,8 @@ @Slf4j @RestController -@RequestMapping("/api/webrtc") @RequiredArgsConstructor +@RequestMapping("/api/webrtc") @Tag(name = "WebRTC API", description = "WebRTC 시그널링 및 ICE 서버 관련 REST API") public class WebRTCApiController { diff --git a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java index 55f82a22..f56dbd37 100644 --- a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java +++ b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java @@ -18,9 +18,9 @@ import java.security.Principal; +@Slf4j @Controller @RequiredArgsConstructor -@Slf4j public class WebRTCSignalingController { private final SimpMessagingTemplate messagingTemplate; diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java new file mode 100644 index 00000000..566847c7 --- /dev/null +++ b/src/test/java/com/back/global/websocket/controller/WebSocketApiControllerTest.java @@ -0,0 +1,275 @@ +package com.back.global.websocket.controller; + +import com.back.global.websocket.service.WebSocketSessionManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WithMockUser +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("WebSocket REST API 컨트롤러") +class WebSocketApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private WebSocketSessionManager sessionManager; + + @Nested + @DisplayName("헬스체크") + class HealthCheckTest { + + @Test + @DisplayName("정상 - 서비스 상태 확인") + void t1() throws Exception { + // given + long totalOnlineUsers = 5L; + given(sessionManager.getTotalOnlineUserCount()).willReturn(totalOnlineUsers); + + // when & then + mockMvc.perform(get("/api/ws/health") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("WebSocket 서비스가 정상 동작중입니다.")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.service").value("WebSocket")) + .andExpect(jsonPath("$.data.status").value("running")) + .andExpect(jsonPath("$.data.timestamp").exists()) + .andExpect(jsonPath("$.data.sessionTTL").value("10분 (Heartbeat 방식)")) + .andExpect(jsonPath("$.data.heartbeatInterval").value("5분")) + .andExpect(jsonPath("$.data.totalOnlineUsers").value(totalOnlineUsers)) + .andExpect(jsonPath("$.data.endpoints").exists()) + .andExpect(jsonPath("$.data.endpoints.websocket").value("/ws")) + .andExpect(jsonPath("$.data.endpoints.heartbeat").value("/app/heartbeat")) + .andExpect(jsonPath("$.data.endpoints.activity").value("/app/activity")); + + verify(sessionManager).getTotalOnlineUserCount(); + } + + @Test + @DisplayName("정상 - 온라인 유저 0명") + void t2() throws Exception { + // given + given(sessionManager.getTotalOnlineUserCount()).willReturn(0L); + + // when & then + mockMvc.perform(get("/api/ws/health") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalOnlineUsers").value(0)); + + verify(sessionManager).getTotalOnlineUserCount(); + } + + @Test + @DisplayName("정상 - 응답 구조 검증") + void t3() throws Exception { + // given + given(sessionManager.getTotalOnlineUserCount()).willReturn(10L); + + // when & then + MvcResult result = mockMvc.perform(get("/api/ws/health") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + + String content = result.getResponse().getContentAsString(); + + assertThat(content).contains("service"); + assertThat(content).contains("status"); + assertThat(content).contains("timestamp"); + assertThat(content).contains("sessionTTL"); + assertThat(content).contains("heartbeatInterval"); + assertThat(content).contains("totalOnlineUsers"); + assertThat(content).contains("endpoints"); + } + + @Test + @DisplayName("정상 - 엔드포인트 정보 포함") + void t4() throws Exception { + // given + given(sessionManager.getTotalOnlineUserCount()).willReturn(3L); + + // when & then + mockMvc.perform(get("/api/ws/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.endpoints.websocket").exists()) + .andExpect(jsonPath("$.data.endpoints.heartbeat").exists()) + .andExpect(jsonPath("$.data.endpoints.activity").exists()) + .andExpect(jsonPath("$.data.endpoints.joinRoom").exists()) + .andExpect(jsonPath("$.data.endpoints.leaveRoom").exists()); + } + + @Test + @DisplayName("정상 - Content-Type 없이도 동작") + void t5() throws Exception { + // given + given(sessionManager.getTotalOnlineUserCount()).willReturn(1L); + + // when & then + mockMvc.perform(get("/api/ws/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + } + + @Nested + @DisplayName("연결 정보 조회") + class ConnectionInfoTest { + + @Test + @DisplayName("정상 - 연결 정보 조회") + void t6() throws Exception { + // when & then + mockMvc.perform(get("/api/ws/info") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("WebSocket 연결 정보")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.websocketUrl").value("/ws")) + .andExpect(jsonPath("$.data.sockjsSupport").value(true)) + .andExpect(jsonPath("$.data.stompVersion").value("1.2")) + .andExpect(jsonPath("$.data.heartbeatInterval").value("5분")) + .andExpect(jsonPath("$.data.sessionTTL").value("10분")); + } + + @Test + @DisplayName("정상 - 구독 토픽 정보 포함") + void t7() throws Exception { + // when & then + mockMvc.perform(get("/api/ws/info") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.subscribeTopics").exists()) + .andExpect(jsonPath("$.data.subscribeTopics.roomChat").value("/topic/rooms/{roomId}/chat")) + .andExpect(jsonPath("$.data.subscribeTopics.privateMessage").value("/user/queue/messages")) + .andExpect(jsonPath("$.data.subscribeTopics.notifications").value("/user/queue/notifications")); + } + + @Test + @DisplayName("정상 - 전송 목적지 정보 포함") + void t8() throws Exception { + // when & then + mockMvc.perform(get("/api/ws/info") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sendDestinations").exists()) + .andExpect(jsonPath("$.data.sendDestinations.heartbeat").value("/app/heartbeat")) + .andExpect(jsonPath("$.data.sendDestinations.activity").value("/app/activity")) + .andExpect(jsonPath("$.data.sendDestinations.roomChat").value("/app/rooms/{roomId}/chat")); + } + + @Test + @DisplayName("정상 - 응답 구조 검증") + void t9() throws Exception { + // when & then + MvcResult result = mockMvc.perform(get("/api/ws/info") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + + String content = result.getResponse().getContentAsString(); + + assertThat(content).contains("websocketUrl"); + assertThat(content).contains("sockjsSupport"); + assertThat(content).contains("stompVersion"); + assertThat(content).contains("heartbeatInterval"); + assertThat(content).contains("sessionTTL"); + assertThat(content).contains("subscribeTopics"); + assertThat(content).contains("sendDestinations"); + } + + @Test + @DisplayName("정상 - SockJS 지원 확인") + void t10() throws Exception { + // when & then + mockMvc.perform(get("/api/ws/info")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sockjsSupport").value(true)) + .andExpect(jsonPath("$.data.stompVersion").value("1.2")); + } + + @Test + @DisplayName("정상 - Content-Type 없이도 동작") + void t11() throws Exception { + // when & then + mockMvc.perform(get("/api/ws/info")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + } + + @Nested + @DisplayName("API 엔드포인트") + class EndpointTest { + + @Test + @DisplayName("정상 - 모든 엔드포인트 JSON 응답") + void t12() throws Exception { + // given + given(sessionManager.getTotalOnlineUserCount()).willReturn(0L); + + // when & then + mockMvc.perform(get("/api/ws/health")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)); + + mockMvc.perform(get("/api/ws/info")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("정상 - RsData 구조 일관성") + void t13() throws Exception { + // given + given(sessionManager.getTotalOnlineUserCount()).willReturn(0L); + + // when & then - health + mockMvc.perform(get("/api/ws/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").exists()) + .andExpect(jsonPath("$.message").exists()) + .andExpect(jsonPath("$.success").exists()) + .andExpect(jsonPath("$.data").exists()); + + // when & then - info + mockMvc.perform(get("/api/ws/info")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").exists()) + .andExpect(jsonPath("$.message").exists()) + .andExpect(jsonPath("$.success").exists()) + .andExpect(jsonPath("$.data").exists()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java new file mode 100644 index 00000000..bfe021bc --- /dev/null +++ b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java @@ -0,0 +1,336 @@ +package com.back.global.websocket.controller; + +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import com.back.global.websocket.dto.HeartbeatMessage; +import com.back.global.websocket.service.WebSocketSessionManager; +import com.back.global.websocket.util.WebSocketErrorHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebSocket 메시지 컨트롤러") +class WebSocketMessageControllerTest { + + @Mock + private WebSocketSessionManager sessionManager; + + @Mock + private WebSocketErrorHelper errorHelper; + + @InjectMocks + private WebSocketMessageController controller; + + private SimpMessageHeaderAccessor headerAccessor; + private Long userId; + private Long roomId; + private String sessionId; + + @BeforeEach + void setUp() { + userId = 10L; + roomId = 1L; + sessionId = "test-session-id"; + + headerAccessor = mock(SimpMessageHeaderAccessor.class); + lenient().when(headerAccessor.getSessionId()).thenReturn(sessionId); + } + + @Nested + @DisplayName("Heartbeat 처리") + class HandleHeartbeatTest { + + @Test + @DisplayName("정상 - Heartbeat 처리") + void t1() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + doNothing().when(sessionManager).updateLastActivity(userId); + + // when + controller.handleHeartbeat(message, headerAccessor); + + // then + verify(sessionManager).updateLastActivity(userId); + verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); + verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); + verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); + } + + @Test + @DisplayName("실패 - userId가 null") + void t2() { + // given + HeartbeatMessage message = new HeartbeatMessage(null); + + // when + controller.handleHeartbeat(message, headerAccessor); + + // then + verify(sessionManager, never()).updateLastActivity(any()); + verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); + } + + @Test + @DisplayName("실패 - CustomException 발생") + void t3() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + CustomException exception = new CustomException(ErrorCode.BAD_REQUEST); + doThrow(exception).when(sessionManager).updateLastActivity(userId); + + // when + controller.handleHeartbeat(message, headerAccessor); + + // then + verify(sessionManager).updateLastActivity(userId); + verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + } + + @Test + @DisplayName("실패 - 일반 Exception 발생") + void t4() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + RuntimeException exception = new RuntimeException("예상치 못한 오류"); + doThrow(exception).when(sessionManager).updateLastActivity(userId); + + // when + controller.handleHeartbeat(message, headerAccessor); + + // then + verify(sessionManager).updateLastActivity(userId); + verify(errorHelper).sendGenericErrorToUser( + eq(sessionId), + any(Exception.class), + eq("Heartbeat 처리 중 오류가 발생했습니다") + ); + } + } + + @Nested + @DisplayName("방 입장 처리") + class HandleJoinRoomTest { + + @Test + @DisplayName("정상 - 방 입장") + void t5() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + doNothing().when(sessionManager).joinRoom(userId, roomId); + + // when + controller.handleJoinRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager).joinRoom(userId, roomId); + verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); + verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); + verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); + } + + @Test + @DisplayName("실패 - userId가 null") + void t6() { + // given + HeartbeatMessage message = new HeartbeatMessage(null); + + // when + controller.handleJoinRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager, never()).joinRoom(any(), any()); + verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); + } + + @Test + @DisplayName("실패 - CustomException 발생") + void t7() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + CustomException exception = new CustomException(ErrorCode.NOT_ROOM_MEMBER); + doThrow(exception).when(sessionManager).joinRoom(userId, roomId); + + // when + controller.handleJoinRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager).joinRoom(userId, roomId); + verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + } + + @Test + @DisplayName("실패 - 일반 Exception 발생") + void t8() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + RuntimeException exception = new RuntimeException("예상치 못한 오류"); + doThrow(exception).when(sessionManager).joinRoom(userId, roomId); + + // when + controller.handleJoinRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager).joinRoom(userId, roomId); + verify(errorHelper).sendGenericErrorToUser( + eq(sessionId), + any(Exception.class), + eq("방 입장 중 오류가 발생했습니다") + ); + } + } + + @Nested + @DisplayName("방 퇴장 처리") + class HandleLeaveRoomTest { + + @Test + @DisplayName("정상 - 방 퇴장") + void t9() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + doNothing().when(sessionManager).leaveRoom(userId, roomId); + + // when + controller.handleLeaveRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager).leaveRoom(userId, roomId); + verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); + verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); + verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); + } + + @Test + @DisplayName("실패 - userId가 null") + void t10() { + // given + HeartbeatMessage message = new HeartbeatMessage(null); + + // when + controller.handleLeaveRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager, never()).leaveRoom(any(), any()); + verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); + } + + @Test + @DisplayName("실패 - CustomException 발생") + void t11() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + CustomException exception = new CustomException(ErrorCode.NOT_ROOM_MEMBER); + doThrow(exception).when(sessionManager).leaveRoom(userId, roomId); + + // when + controller.handleLeaveRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager).leaveRoom(userId, roomId); + verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + } + + @Test + @DisplayName("실패 - 일반 Exception 발생") + void t12() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + RuntimeException exception = new RuntimeException("예상치 못한 오류"); + doThrow(exception).when(sessionManager).leaveRoom(userId, roomId); + + // when + controller.handleLeaveRoom(roomId, message, headerAccessor); + + // then + verify(sessionManager).leaveRoom(userId, roomId); + verify(errorHelper).sendGenericErrorToUser( + eq(sessionId), + any(Exception.class), + eq("방 퇴장 중 오류가 발생했습니다") + ); + } + } + + @Nested + @DisplayName("활동 신호 처리") + class HandleActivityTest { + + @Test + @DisplayName("정상 - 활동 신호 처리") + void t13() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + doNothing().when(sessionManager).updateLastActivity(userId); + + // when + controller.handleActivity(message, headerAccessor); + + // then + verify(sessionManager).updateLastActivity(userId); + verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); + verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); + verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); + } + + @Test + @DisplayName("실패 - userId가 null") + void t14() { + // given + HeartbeatMessage message = new HeartbeatMessage(null); + + // when + controller.handleActivity(message, headerAccessor); + + // then + verify(sessionManager, never()).updateLastActivity(any()); + verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); + } + + @Test + @DisplayName("실패 - CustomException 발생") + void t15() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + CustomException exception = new CustomException(ErrorCode.BAD_REQUEST); + doThrow(exception).when(sessionManager).updateLastActivity(userId); + + // when + controller.handleActivity(message, headerAccessor); + + // then + verify(sessionManager).updateLastActivity(userId); + verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + } + + @Test + @DisplayName("실패 - 일반 Exception 발생") + void t16() { + // given + HeartbeatMessage message = new HeartbeatMessage(userId); + RuntimeException exception = new RuntimeException("예상치 못한 오류"); + doThrow(exception).when(sessionManager).updateLastActivity(userId); + + // when + controller.handleActivity(message, headerAccessor); + + // then + verify(sessionManager).updateLastActivity(userId); + verify(errorHelper).sendGenericErrorToUser( + eq(sessionId), + any(Exception.class), + eq("활동 신호 처리 중 오류가 발생했습니다") + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java new file mode 100644 index 00000000..13c66e6e --- /dev/null +++ b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java @@ -0,0 +1,227 @@ +package com.back.global.websocket.webrtc.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@WithMockUser +@DisplayName("WebRTC API 컨트롤러") +class WebRTCApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Nested + @DisplayName("ICE 서버 조회") + class GetIceServersTest { + + @Test + @DisplayName("기본 조회") + void t1() throws Exception { + // when & then + MvcResult result = mockMvc.perform(get("/api/webrtc/ice-servers") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("ICE 서버 설정 조회 성공")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.iceServers").isArray()) + .andExpect(jsonPath("$.data.iceServers").isNotEmpty()) + .andReturn(); + + // 응답 본문 검증 + String content = result.getResponse().getContentAsString(); + assertThat(content).contains("stun:"); + } + + @Test + @DisplayName("userId, roomId 파라미터") + void t2() throws Exception { + // given + Long userId = 1L; + Long roomId = 100L; + + // when & then + mockMvc.perform(get("/api/webrtc/ice-servers") + .param("userId", userId.toString()) + .param("roomId", roomId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("ICE 서버 설정 조회 성공")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.iceServers").isArray()) + .andExpect(jsonPath("$.data.iceServers").isNotEmpty()); + } + + @Test + @DisplayName("userId만") + void t3() throws Exception { + // given + Long userId = 1L; + + // when & then + mockMvc.perform(get("/api/webrtc/ice-servers") + .param("userId", userId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.iceServers").isArray()); + } + + @Test + @DisplayName("roomId만") + void t4() throws Exception { + // given + Long roomId = 100L; + + // when & then + mockMvc.perform(get("/api/webrtc/ice-servers") + .param("roomId", roomId.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.iceServers").isArray()); + } + + @Test + @DisplayName("Google STUN 서버 포함") + void t5() throws Exception { + // when & then + MvcResult result = mockMvc.perform(get("/api/webrtc/ice-servers") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + + String content = result.getResponse().getContentAsString(); + + // Google STUN 서버 확인 + assertThat(content).contains("stun.l.google.com"); + } + + @Test + @DisplayName("응답 구조 검증") + void t6() throws Exception { + // when & then + MvcResult result = mockMvc.perform(get("/api/webrtc/ice-servers") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + + String content = result.getResponse().getContentAsString(); + assertThat(content).isNotEmpty(); + + // JSON 구조 검증 + assertThat(content).contains("code"); + assertThat(content).contains("message"); + assertThat(content).contains("data"); + assertThat(content).contains("success"); + assertThat(content).contains("iceServers"); + } + } + + @Nested + @DisplayName("Health Check") + class HealthCheckTest { + + @Test + @DisplayName("정상 응답") + void t7() throws Exception { + // when & then + mockMvc.perform(get("/api/webrtc/health") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("WebRTC 서비스 정상 작동 중")) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("반복 호출") + void t8() throws Exception { + // when & then + for (int i = 0; i < 3; i++) { + mockMvc.perform(get("/api/webrtc/health") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.success").value(true)); + } + } + + @Test + @DisplayName("응답 구조 검증") + void t9() throws Exception { + // when & then + MvcResult result = mockMvc.perform(get("/api/webrtc/health") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + + String content = result.getResponse().getContentAsString(); + + // RsData 구조 검증 + assertThat(content).contains("code"); + assertThat(content).contains("message"); + assertThat(content).contains("success"); + } + } + + @Nested + @DisplayName("엔드포인트") + class EndpointTest { + + @Test + @DisplayName("Content-Type 생략 가능") + void t10() throws Exception { + // when & then + mockMvc.perform(get("/api/webrtc/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("JSON 응답") + void t11() throws Exception { + // ICE servers endpoint + mockMvc.perform(get("/api/webrtc/ice-servers")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)); + + // Health endpoint + mockMvc.perform(get("/api/webrtc/health")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java new file mode 100644 index 00000000..9564c475 --- /dev/null +++ b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java @@ -0,0 +1,394 @@ +package com.back.global.websocket.webrtc.controller; + +import com.back.global.security.user.CustomUserDetails; +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaToggleRequest; +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaType; +import com.back.global.websocket.webrtc.dto.signal.*; +import com.back.global.websocket.webrtc.service.WebRTCSignalValidator; +import com.back.global.websocket.util.WebSocketErrorHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.security.Principal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebRTC 시그널링 컨트롤러") +class WebRTCSignalingControllerTest { + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @Mock + private WebSocketErrorHelper errorHelper; + + @Mock + private WebRTCSignalValidator validator; + + @InjectMocks + private WebRTCSignalingController controller; + + private SimpMessageHeaderAccessor headerAccessor; + private CustomUserDetails userDetails; + private Authentication authentication; + private Long roomId; + private Long fromUserId; + private Long targetUserId; + + @BeforeEach + void setUp() { + roomId = 1L; + fromUserId = 10L; + targetUserId = 20L; + + userDetails = mock(CustomUserDetails.class); + lenient().when(userDetails.getUserId()).thenReturn(fromUserId); + lenient().when(userDetails.getUsername()).thenReturn("testUser"); + + authentication = new UsernamePasswordAuthenticationToken(userDetails, null, null); + + headerAccessor = mock(SimpMessageHeaderAccessor.class); + lenient().when(headerAccessor.getSessionId()).thenReturn("test-session-id"); + } + + @Nested + @DisplayName("Offer 처리") + class HandleOfferTest { + + @Test + @DisplayName("정상 - Offer 메시지 전송") + void t1() { + // given + String sdp = "test-sdp-offer"; + WebRTCOfferRequest request = new WebRTCOfferRequest( + roomId, + targetUserId, + sdp, + WebRTCMediaType.AUDIO + ); + + doNothing().when(validator).validateSignal(roomId, fromUserId, targetUserId); + + // when + controller.handleOffer(request, headerAccessor, authentication); + + // then + verify(validator).validateSignal(roomId, fromUserId, targetUserId); + + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(WebRTCSignalResponse.class); + verify(messagingTemplate).convertAndSend( + destinationCaptor.capture(), + responseCaptor.capture() + ); + + assertThat(destinationCaptor.getValue()).isEqualTo("/topic/room/" + roomId + "/webrtc"); + + WebRTCSignalResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(WebRTCSignalType.OFFER); + assertThat(response.fromUserId()).isEqualTo(fromUserId); + assertThat(response.targetUserId()).isEqualTo(targetUserId); + assertThat(response.roomId()).isEqualTo(roomId); + assertThat(response.sdp()).isEqualTo(sdp); + assertThat(response.mediaType()).isEqualTo(WebRTCMediaType.AUDIO); + } + + @Test + @DisplayName("실패 - 인증 정보 없음") + void t2() { + // given + WebRTCOfferRequest request = new WebRTCOfferRequest( + roomId, + targetUserId, + "test-sdp", + WebRTCMediaType.AUDIO + ); + Principal invalidPrincipal = mock(Principal.class); + + // when + controller.handleOffer(request, headerAccessor, invalidPrincipal); + + // then + verify(errorHelper).sendUnauthorizedError("test-session-id"); + verify(validator, never()).validateSignal(any(), any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + } + + @Test + @DisplayName("실패 - 검증 오류") + void t3() { + // given + WebRTCOfferRequest request = new WebRTCOfferRequest( + roomId, + targetUserId, + "test-sdp", + WebRTCMediaType.VIDEO + ); + + doThrow(new RuntimeException("검증 실패")) + .when(validator).validateSignal(roomId, fromUserId, targetUserId); + + // when + controller.handleOffer(request, headerAccessor, authentication); + + // then + verify(validator).validateSignal(roomId, fromUserId, targetUserId); + verify(errorHelper).sendGenericErrorToUser( + eq("test-session-id"), + any(Exception.class), + eq("Offer 전송 중 오류가 발생했습니다") + ); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + } + } + + @Nested + @DisplayName("Answer 처리") + class HandleAnswerTest { + + @Test + @DisplayName("정상 - Answer 메시지 전송") + void t4() { + // given + String sdp = "test-sdp-answer"; + WebRTCAnswerRequest request = new WebRTCAnswerRequest( + roomId, + targetUserId, + sdp, + WebRTCMediaType.SCREEN + ); + + doNothing().when(validator).validateSignal(roomId, fromUserId, targetUserId); + + // when + controller.handleAnswer(request, headerAccessor, authentication); + + // then + verify(validator).validateSignal(roomId, fromUserId, targetUserId); + + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(WebRTCSignalResponse.class); + verify(messagingTemplate).convertAndSend( + destinationCaptor.capture(), + responseCaptor.capture() + ); + + assertThat(destinationCaptor.getValue()).isEqualTo("/topic/room/" + roomId + "/webrtc"); + + WebRTCSignalResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(WebRTCSignalType.ANSWER); + assertThat(response.fromUserId()).isEqualTo(fromUserId); + assertThat(response.targetUserId()).isEqualTo(targetUserId); + assertThat(response.sdp()).isEqualTo(sdp); + } + + @Test + @DisplayName("실패 - 인증 정보 없음") + void t5() { + // given + WebRTCAnswerRequest request = new WebRTCAnswerRequest( + roomId, + targetUserId, + "test-sdp", + WebRTCMediaType.AUDIO + ); + Principal invalidPrincipal = mock(Principal.class); + + // when + controller.handleAnswer(request, headerAccessor, invalidPrincipal); + + // then + verify(errorHelper).sendUnauthorizedError("test-session-id"); + verify(validator, never()).validateSignal(any(), any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + } + } + + @Nested + @DisplayName("ICE Candidate 처리") + class HandleIceCandidateTest { + + @Test + @DisplayName("정상 - ICE Candidate 전송") + void t6() { + // given + WebRTCIceCandidateRequest request = new WebRTCIceCandidateRequest( + roomId, + targetUserId, + "candidate:1234567890", + "audio", + 0 + ); + + doNothing().when(validator).validateSignal(roomId, fromUserId, targetUserId); + + // when + controller.handleIceCandidate(request, headerAccessor, authentication); + + // then + verify(validator).validateSignal(roomId, fromUserId, targetUserId); + + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(WebRTCSignalResponse.class); + verify(messagingTemplate).convertAndSend( + destinationCaptor.capture(), + responseCaptor.capture() + ); + + assertThat(destinationCaptor.getValue()).isEqualTo("/topic/room/" + roomId + "/webrtc"); + + WebRTCSignalResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(WebRTCSignalType.ICE_CANDIDATE); + assertThat(response.fromUserId()).isEqualTo(fromUserId); + assertThat(response.targetUserId()).isEqualTo(targetUserId); + assertThat(response.candidate()).isEqualTo("candidate:1234567890"); + assertThat(response.sdpMid()).isEqualTo("audio"); + assertThat(response.sdpMLineIndex()).isEqualTo(0); + } + + @Test + @DisplayName("실패 - 검증 오류") + void t7() { + // given + WebRTCIceCandidateRequest request = new WebRTCIceCandidateRequest( + roomId, + targetUserId, + "candidate", + "audio", + 0 + ); + + doThrow(new RuntimeException("검증 실패")) + .when(validator).validateSignal(roomId, fromUserId, targetUserId); + + // when + controller.handleIceCandidate(request, headerAccessor, authentication); + + // then + verify(errorHelper).sendGenericErrorToUser( + eq("test-session-id"), + any(Exception.class), + eq("ICE Candidate 전송 중 오류가 발생했습니다") + ); + } + } + + @Nested + @DisplayName("미디어 상태 토글") + class HandleMediaToggleTest { + + @Test + @DisplayName("정상 - 오디오 활성화") + void t8() { + // given + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.AUDIO, + true + ); + + doNothing().when(validator).validateMediaStateChange(roomId, fromUserId); + + // when + controller.handleMediaToggle(request, headerAccessor, authentication); + + // then + verify(validator).validateMediaStateChange(roomId, fromUserId); + + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Object.class); + verify(messagingTemplate).convertAndSend( + destinationCaptor.capture(), + payloadCaptor.capture() + ); + + assertThat(destinationCaptor.getValue()).isEqualTo("/topic/room/" + roomId + "/media-status"); + } + + @Test + @DisplayName("정상 - 비디오 비활성화") + void t9() { + // given + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.VIDEO, + false + ); + + doNothing().when(validator).validateMediaStateChange(roomId, fromUserId); + + // when + controller.handleMediaToggle(request, headerAccessor, authentication); + + // then + verify(validator).validateMediaStateChange(roomId, fromUserId); + + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Object.class); + verify(messagingTemplate).convertAndSend( + destinationCaptor.capture(), + payloadCaptor.capture() + ); + + assertThat(destinationCaptor.getValue()).isEqualTo("/topic/room/" + roomId + "/media-status"); + } + + @Test + @DisplayName("실패 - 인증 정보 없음") + void t10() { + // given + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.AUDIO, + true + ); + Principal invalidPrincipal = mock(Principal.class); + + // when + controller.handleMediaToggle(request, headerAccessor, invalidPrincipal); + + // then + verify(errorHelper).sendUnauthorizedError("test-session-id"); + verify(validator, never()).validateMediaStateChange(any(), any()); + } + + @Test + @DisplayName("실패 - 검증 오류") + void t11() { + // given + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.SCREEN, + true + ); + + doThrow(new RuntimeException("검증 실패")) + .when(validator).validateMediaStateChange(roomId, fromUserId); + + // when + controller.handleMediaToggle(request, headerAccessor, authentication); + + // then + verify(errorHelper).sendGenericErrorToUser( + eq("test-session-id"), + any(Exception.class), + eq("미디어 상태 변경 중 오류가 발생했습니다") + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java b/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java new file mode 100644 index 00000000..fef7bda8 --- /dev/null +++ b/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java @@ -0,0 +1,253 @@ +package com.back.global.websocket.webrtc.service; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.domain.user.entity.User; +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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebRTC 시그널링 메세지 검증") +class WebRTCSignalValidatorTest { + + @Mock + private RoomMemberRepository roomMemberRepository; + + @InjectMocks + private WebRTCSignalValidator validator; + + private Long roomId; + private Long fromUserId; + private Long targetUserId; + private RoomMember onlineFromMember; + private RoomMember onlineTargetMember; + private RoomMember offlineFromMember; + private RoomMember offlineTargetMember; + + @BeforeEach + void setUp() { + roomId = 1L; + fromUserId = 10L; + targetUserId = 20L; + + Room mockRoom = mock(Room.class); + User fromUser = mock(User.class); + User targetUser = mock(User.class); + + // 온라인 멤버들 + onlineFromMember = RoomMember.createMember(mockRoom, fromUser); + onlineFromMember.updateOnlineStatus(true); + + onlineTargetMember = RoomMember.createMember(mockRoom, targetUser); + onlineTargetMember.updateOnlineStatus(true); + + // 오프라인 멤버들 + offlineFromMember = RoomMember.createMember(mockRoom, fromUser); + offlineFromMember.updateOnlineStatus(false); + + offlineTargetMember = RoomMember.createMember(mockRoom, targetUser); + offlineTargetMember.updateOnlineStatus(false); + } + + @Nested + @DisplayName("시그널 검증") + class ValidateSignalTest { + + @Test + @DisplayName("정상 - 모든 조건 만족") + void t1() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(onlineFromMember)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId)) + .willReturn(Optional.of(onlineTargetMember)); + + // when & then + assertThatCode(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) + .doesNotThrowAnyException(); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); + } + + @Test + @DisplayName("실패 - 자기 자신에게 시그널 전송") + void t2() { + // given + Long sameUserId = 10L; + + // when & then + assertThatThrownBy(() -> validator.validateSignal(roomId, sameUserId, sameUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.BAD_REQUEST); + } + + @Test + @DisplayName("실패 - 발신자가 방에 없음") + void t3() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + } + + @Test + @DisplayName("실패 - 발신자가 오프라인") + void t4() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(offlineFromMember)); + + // when & then + assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + } + + @Test + @DisplayName("실패 - 수신자가 방에 없음") + void t5() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(onlineFromMember)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); + } + + @Test + @DisplayName("실패 - 수신자가 오프라인") + void t6() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(onlineFromMember)); + given(roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId)) + .willReturn(Optional.of(offlineTargetMember)); + + // when & then + assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); + } + + @Test + @DisplayName("실패 - 발신자와 수신자 모두 오프라인") + void t7() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(offlineFromMember)); + + // when & then + assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + } + } + + @Nested + @DisplayName("미디어 상태 변경 검증") + class ValidateMediaStateChangeTest { + + @Test + @DisplayName("정상 - 온라인 멤버") + void t8() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(onlineFromMember)); + + // when & then + assertThatCode(() -> validator.validateMediaStateChange(roomId, fromUserId)) + .doesNotThrowAnyException(); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + } + + @Test + @DisplayName("실패 - 방에 없는 사용자") + void t9() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> validator.validateMediaStateChange(roomId, fromUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + } + + @Test + @DisplayName("실패 - 오프라인 사용자") + void t10() { + // given + given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) + .willReturn(Optional.of(offlineFromMember)); + + // when & then + assertThatThrownBy(() -> validator.validateMediaStateChange(roomId, fromUserId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); + + verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); + } + + @Test + @DisplayName("정상 - 다른 방의 온라인 멤버") + void t11() { + // given + Long differentRoomId = 999L; + Room differentRoom = mock(Room.class); + User user = mock(User.class); + + RoomMember memberInDifferentRoom = RoomMember.createMember(differentRoom, user); + memberInDifferentRoom.updateOnlineStatus(true); + + given(roomMemberRepository.findByRoomIdAndUserId(differentRoomId, fromUserId)) + .willReturn(Optional.of(memberInDifferentRoom)); + + // when & then + assertThatCode(() -> validator.validateMediaStateChange(differentRoomId, fromUserId)) + .doesNotThrowAnyException(); + + verify(roomMemberRepository).findByRoomIdAndUserId(differentRoomId, fromUserId); + } + } +} \ No newline at end of file