From 40ead7ed9b7dd72f4b0dd3f77bc5b85eabe296d7 Mon Sep 17 00:00:00 2001 From: PHJ Date: Tue, 4 Aug 2026 16:52:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=9B=88=EB=A0=A8=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91/=EC=A2=85=EB=A3=8C=20API=20=EA=B5=AC=ED=98=84=20-=20#?= =?UTF-8?q?40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/saferoute/SafeRouteApplication.java | 2 + .../controller/TrainingSessionController.java | 23 +++ .../training/entity/TrainingSession.java | 6 + .../repository/TrainingSessionRepository.java | 4 + .../scheduler/TrainingTimeoutScheduler.java | 22 +++ .../service/TrainingSessionService.java | 87 +++++++- .../global/api/error/TrainingErrorCode.java | 21 ++ .../api/response/TrainingSuccessCode.java | 21 ++ src/main/resources/static/test.html | 124 ++++++++++++ .../TrainingSessionControllerTest.java | 132 +++++++++++++ .../TrainingTimeoutSchedulerTest.java | 30 +++ .../service/TrainingSessionServiceTest.java | 187 ++++++++++++++++++ .../service/WebSocketIntegrationTest.java | 94 +++++++++ 13 files changed, 745 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/saferoute/domain/training/scheduler/TrainingTimeoutScheduler.java create mode 100644 src/main/java/com/saferoute/global/api/error/TrainingErrorCode.java create mode 100644 src/main/java/com/saferoute/global/api/response/TrainingSuccessCode.java create mode 100644 src/main/resources/static/test.html create mode 100644 src/test/java/com/saferoute/domain/training/controller/TrainingSessionControllerTest.java create mode 100644 src/test/java/com/saferoute/domain/training/scheduler/TrainingTimeoutSchedulerTest.java create mode 100644 src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java diff --git a/src/main/java/com/saferoute/SafeRouteApplication.java b/src/main/java/com/saferoute/SafeRouteApplication.java index 6383f0ab..63510e08 100644 --- a/src/main/java/com/saferoute/SafeRouteApplication.java +++ b/src/main/java/com/saferoute/SafeRouteApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class SafeRouteApplication { diff --git a/src/main/java/com/saferoute/domain/training/controller/TrainingSessionController.java b/src/main/java/com/saferoute/domain/training/controller/TrainingSessionController.java index f5009448..922456da 100644 --- a/src/main/java/com/saferoute/domain/training/controller/TrainingSessionController.java +++ b/src/main/java/com/saferoute/domain/training/controller/TrainingSessionController.java @@ -3,6 +3,8 @@ import com.saferoute.domain.training.dto.CreateSessionRequest; import com.saferoute.domain.training.dto.TrainingSessionResponse; import com.saferoute.domain.training.service.TrainingSessionService; +import com.saferoute.global.api.response.ApiResponse; +import com.saferoute.global.api.response.TrainingSuccessCode; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -26,4 +28,25 @@ public ResponseEntity createTrainingSession( @PathVariable("scenarioId") UUID scenarioId) { return ResponseEntity.ok(trainingSessionService.create(request, scenarioId)); } + + @PostMapping("/{sessionId}/start") + public ResponseEntity> startTrainingSession( + @PathVariable("sessionId") UUID sessionId) { + TrainingSessionResponse response = trainingSessionService.start(sessionId); + return ResponseEntity.ok(ApiResponse.success(TrainingSuccessCode.TRAINING_STARTED, response)); + } + + @PostMapping("/{sessionId}/end") + public ResponseEntity> endTrainingSession( + @PathVariable("sessionId") UUID sessionId) { + TrainingSessionResponse response = trainingSessionService.end(sessionId); + return ResponseEntity.ok(ApiResponse.success(TrainingSuccessCode.TRAINING_ENDED, response)); + } + + @PostMapping("/{sessionId}/force-end") + public ResponseEntity> forceEndTrainingSession( + @PathVariable("sessionId") UUID sessionId) { + TrainingSessionResponse response = trainingSessionService.forceEnd(sessionId); + return ResponseEntity.ok(ApiResponse.success(TrainingSuccessCode.TRAINING_FORCE_ENDED, response)); + } } diff --git a/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java b/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java index f624ee96..33ab45dc 100644 --- a/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java +++ b/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java @@ -78,6 +78,12 @@ public static TrainingSession create(TrainingStatus status, Instant startedAt, U return new TrainingSession(status, startedAt, admin, scenario); } + // 관리자가 훈련 시작 버튼을 누른 시각으로 실제 시작 시각을 갱신하며 RUNNING으로 전이한다. + public void start(Instant startedAt) { + this.status = TrainingStatus.RUNNING; + this.startedAt = startedAt; + } + // 훈련 정상 종료 public void complete(Instant endedAt) { this.status = TrainingStatus.COMPLETED; diff --git a/src/main/java/com/saferoute/domain/training/repository/TrainingSessionRepository.java b/src/main/java/com/saferoute/domain/training/repository/TrainingSessionRepository.java index a407a3ac..f7813249 100644 --- a/src/main/java/com/saferoute/domain/training/repository/TrainingSessionRepository.java +++ b/src/main/java/com/saferoute/domain/training/repository/TrainingSessionRepository.java @@ -1,9 +1,13 @@ package com.saferoute.domain.training.repository; import com.saferoute.domain.training.entity.TrainingSession; +import com.saferoute.domain.training.entity.TrainingStatus; +import java.time.Instant; +import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface TrainingSessionRepository extends JpaRepository { + List findByStatusAndStartedAtBefore(TrainingStatus status, Instant threshold); } diff --git a/src/main/java/com/saferoute/domain/training/scheduler/TrainingTimeoutScheduler.java b/src/main/java/com/saferoute/domain/training/scheduler/TrainingTimeoutScheduler.java new file mode 100644 index 00000000..4d56b1df --- /dev/null +++ b/src/main/java/com/saferoute/domain/training/scheduler/TrainingTimeoutScheduler.java @@ -0,0 +1,22 @@ +package com.saferoute.domain.training.scheduler; + +import com.saferoute.domain.training.service.TrainingSessionService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +// RUNNING 세션 중 10분 하드 타임아웃(TrainingSessionService.TRAINING_TIMEOUT)을 넘긴 세션을 +// 주기적으로 스캔해 FAILED 처리한다. 텔레메트리 기반 3분 공백 정밀 판정은 카메라 연동 후 별도 구현. +@Component +@RequiredArgsConstructor +public class TrainingTimeoutScheduler { + + private static final long SCAN_INTERVAL_MS = 30_000; + + private final TrainingSessionService trainingSessionService; + + @Scheduled(fixedDelay = SCAN_INTERVAL_MS) + public void scanTimedOutSessions() { + trainingSessionService.failTimedOutSessions(); + } +} diff --git a/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java b/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java index d51a27a3..e3b504c1 100644 --- a/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java +++ b/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java @@ -13,27 +13,38 @@ import com.saferoute.domain.training.repository.TrainingSessionRepository; import com.saferoute.domain.user.entity.User; import com.saferoute.domain.user.repository.UserRepository; +import com.saferoute.global.api.error.TrainingErrorCode; +import com.saferoute.global.api.exception.ApiException; +import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher; import java.math.BigDecimal; +import java.time.Duration; import java.time.Instant; -import java.util.NoSuchElementException; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class TrainingSessionService { + // 텔레메트리(personCount) 수신 파이프라인이 아직 없어, 3분 공백 기반 정밀 타임아웃 판정 대신 + // 1단계로 RUNNING 세션에 대한 단순 하드 타임아웃만 적용한다. 카메라 연동 완료 후 별도 이슈에서 대체 예정. + public static final Duration TRAINING_TIMEOUT = Duration.ofMinutes(10); + private final UserRepository userRepository; private final TrainingSessionRepository trainingSessionRepository; private final TrainingScenarioRepository trainingScenarioRepository; + private final TrainingEventPublisher trainingEventPublisher; public TrainingSessionResponse create(CreateSessionRequest request, UUID scenarioId) { User user = userRepository.findById(request.getAdminId()) - .orElseThrow(NoSuchElementException::new); - TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId).orElseThrow( - NoSuchElementException::new); + .orElseThrow(() -> new ApiException(TrainingErrorCode.ADMIN_NOT_FOUND)); + TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId) + .orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SCENARIO_NOT_FOUND)); TrainingSession trainingSession = TrainingSession.create( request.getStatus(), @@ -47,8 +58,7 @@ public TrainingSessionResponse create(CreateSessionRequest request, UUID scenari @Transactional(readOnly = true) public TrainingStatusResponse getTrainingStatus(UUID sessionId) { - TrainingSession session = trainingSessionRepository.findById(sessionId).orElseThrow( - NoSuchElementException::new); + TrainingSession session = findSession(sessionId); TrainingScenario scenario = session.getScenario(); Building building = scenario.getBuilding(); @@ -69,6 +79,67 @@ public TrainingStatusResponse getTrainingStatus(UUID sessionId) { session.getCurrentSurvivalRate() != null ? session.getCurrentSurvivalRate() : BigDecimal.ZERO ); } - throw new IllegalStateException("지원하지 않는 상태입니다: " + session.getStatus()); + throw new ApiException(TrainingErrorCode.UNSUPPORTED_STATUS); + } + + @Transactional + public TrainingSessionResponse start(UUID sessionId) { + TrainingSession session = findSession(sessionId); + + if (session.getStatus() != TrainingStatus.SCHEDULED) { + throw new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION); + } + + session.start(Instant.now()); + trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); + + return TrainingSessionResponse.from(session); + } + + @Transactional + public TrainingSessionResponse end(UUID sessionId) { + TrainingSession session = findSession(sessionId); + + if (session.getStatus() != TrainingStatus.RUNNING) { + throw new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION); + } + + session.complete(Instant.now()); + trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); + + return TrainingSessionResponse.from(session); + } + + @Transactional + public TrainingSessionResponse forceEnd(UUID sessionId) { + TrainingSession session = findSession(sessionId); + + if (session.getStatus() != TrainingStatus.RUNNING) { + throw new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION); + } + + session.stop(Instant.now()); + trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); + + return TrainingSessionResponse.from(session); + } + + // 10분 하드 타임아웃을 넘긴 RUNNING 세션을 스케줄러가 주기적으로 호출해 FAILED 처리한다. + @Transactional + public void failTimedOutSessions() { + Instant threshold = Instant.now().minus(TRAINING_TIMEOUT); + List timedOutSessions = + trainingSessionRepository.findByStatusAndStartedAtBefore(TrainingStatus.RUNNING, threshold); + + for (TrainingSession session : timedOutSessions) { + session.fail(Instant.now()); + trainingEventPublisher.publishTrainingStatusUpdatedAfterCommit(session); + log.info("훈련 세션 타임아웃 처리: sessionId={}", session.getId()); + } + } + + private TrainingSession findSession(UUID sessionId) { + return trainingSessionRepository.findById(sessionId) + .orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SESSION_NOT_FOUND)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/saferoute/global/api/error/TrainingErrorCode.java b/src/main/java/com/saferoute/global/api/error/TrainingErrorCode.java new file mode 100644 index 00000000..de125919 --- /dev/null +++ b/src/main/java/com/saferoute/global/api/error/TrainingErrorCode.java @@ -0,0 +1,21 @@ +package com.saferoute.global.api.error; + +import com.saferoute.global.api.code.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TrainingErrorCode implements BaseErrorCode { + + TRAINING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "TRAINING001", "훈련 세션을 찾을 수 없습니다."), + ADMIN_NOT_FOUND(HttpStatus.NOT_FOUND, "TRAINING002", "관리자를 찾을 수 없습니다."), + TRAINING_SCENARIO_NOT_FOUND(HttpStatus.NOT_FOUND, "TRAINING003", "훈련 시나리오를 찾을 수 없습니다."), + INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "TRAINING004", "현재 상태에서는 요청한 전이를 수행할 수 없습니다."), + UNSUPPORTED_STATUS(HttpStatus.CONFLICT, "TRAINING005", "지원하지 않는 훈련 상태입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/saferoute/global/api/response/TrainingSuccessCode.java b/src/main/java/com/saferoute/global/api/response/TrainingSuccessCode.java new file mode 100644 index 00000000..a38d2179 --- /dev/null +++ b/src/main/java/com/saferoute/global/api/response/TrainingSuccessCode.java @@ -0,0 +1,21 @@ +package com.saferoute.global.api.response; + +import com.saferoute.global.api.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TrainingSuccessCode implements BaseCode { + + TRAINING_SESSION_CREATED(HttpStatus.CREATED, "TRAINING_SUCCESS_001", "훈련 세션이 생성되었습니다."), + TRAINING_STATUS_FOUND(HttpStatus.OK, "TRAINING_SUCCESS_002", "훈련 상태 조회에 성공했습니다."), + TRAINING_STARTED(HttpStatus.OK, "TRAINING_SUCCESS_003", "훈련이 시작되었습니다."), + TRAINING_ENDED(HttpStatus.OK, "TRAINING_SUCCESS_004", "훈련이 정상 종료되었습니다."), + TRAINING_FORCE_ENDED(HttpStatus.OK, "TRAINING_SUCCESS_005", "훈련이 강제 종료되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/resources/static/test.html b/src/main/resources/static/test.html new file mode 100644 index 00000000..afcf6b55 --- /dev/null +++ b/src/main/resources/static/test.html @@ -0,0 +1,124 @@ + + + + + SafeRoute WebSocket 테스트 (오프라인 동작 - CDN 미사용) + + + + + +

SafeRoute WebSocket 테스트

+

stomp.js가 이 파일 안에 내장되어 있어 외부 CDN 접속 없이 동작합니다.

+ + + + + + + + + + + + + +
상태: 대기 중
+
+ + + + + \ No newline at end of file diff --git a/src/test/java/com/saferoute/domain/training/controller/TrainingSessionControllerTest.java b/src/test/java/com/saferoute/domain/training/controller/TrainingSessionControllerTest.java new file mode 100644 index 00000000..eb257e19 --- /dev/null +++ b/src/test/java/com/saferoute/domain/training/controller/TrainingSessionControllerTest.java @@ -0,0 +1,132 @@ +package com.saferoute.domain.training.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.saferoute.domain.training.dto.TrainingSessionResponse; +import com.saferoute.domain.training.entity.TrainingStatus; +import com.saferoute.domain.training.service.TrainingSessionService; +import com.saferoute.global.api.error.TrainingErrorCode; +import com.saferoute.global.api.exception.ApiException; +import com.saferoute.global.config.SecurityConfig; +import com.saferoute.global.security.JwtAuthenticationFilter; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +// IoTLightControllerTest와 동일한 이유로 JwtAuthenticationFilter/SecurityConfig를 슬라이스에서 제외한다. +@WebMvcTest( + controllers = TrainingSessionController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = {JwtAuthenticationFilter.class, SecurityConfig.class} + ) +) +@AutoConfigureMockMvc(addFilters = false) +class TrainingSessionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TrainingSessionService trainingSessionService; + + private final UUID sessionId = UUID.randomUUID(); + + private TrainingSessionResponse sampleResponse(TrainingStatus status) { + return TrainingSessionResponse.builder() + .status(status) + .startedAt(Instant.now()) + .adminName("박현지") + .scenarioName("정기 훈련") + .build(); + } + + // === start === + + @Test + @DisplayName("POST /sessions/{sessionId}/start - 훈련을 시작하면 200을 반환한다") + void startTrainingSession_success() throws Exception { + given(trainingSessionService.start(sessionId)).willReturn(sampleResponse(TrainingStatus.RUNNING)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/start", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.status").value("RUNNING")); + } + + @Test + @DisplayName("POST /sessions/{sessionId}/start - SCHEDULED가 아닌 세션이면 409를 반환한다") + void startTrainingSession_invalidTransition_returnsConflict() throws Exception { + given(trainingSessionService.start(eq(sessionId))) + .willThrow(new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/start", sessionId)) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("POST /sessions/{sessionId}/start - 세션이 없으면 404를 반환한다") + void startTrainingSession_notFound_returns404() throws Exception { + given(trainingSessionService.start(eq(sessionId))) + .willThrow(new ApiException(TrainingErrorCode.TRAINING_SESSION_NOT_FOUND)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/start", sessionId)) + .andExpect(status().isNotFound()); + } + + // === end === + + @Test + @DisplayName("POST /sessions/{sessionId}/end - 정상 종료하면 200을 반환한다") + void endTrainingSession_success() throws Exception { + given(trainingSessionService.end(sessionId)).willReturn(sampleResponse(TrainingStatus.COMPLETED)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/end", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.status").value("COMPLETED")); + } + + @Test + @DisplayName("POST /sessions/{sessionId}/end - RUNNING이 아닌 세션이면 409를 반환한다") + void endTrainingSession_invalidTransition_returnsConflict() throws Exception { + given(trainingSessionService.end(eq(sessionId))) + .willThrow(new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/end", sessionId)) + .andExpect(status().isConflict()); + } + + // === force-end === + + @Test + @DisplayName("POST /sessions/{sessionId}/force-end - 강제 종료하면 200을 반환한다") + void forceEndTrainingSession_success() throws Exception { + given(trainingSessionService.forceEnd(sessionId)).willReturn(sampleResponse(TrainingStatus.STOPPED)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/force-end", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.status").value("STOPPED")); + } + + @Test + @DisplayName("POST /sessions/{sessionId}/force-end - RUNNING이 아닌 세션이면 409를 반환한다") + void forceEndTrainingSession_invalidTransition_returnsConflict() throws Exception { + given(trainingSessionService.forceEnd(eq(sessionId))) + .willThrow(new ApiException(TrainingErrorCode.INVALID_STATUS_TRANSITION)); + + mockMvc.perform(post("/api/v1/sessions/{sessionId}/force-end", sessionId)) + .andExpect(status().isConflict()); + } +} diff --git a/src/test/java/com/saferoute/domain/training/scheduler/TrainingTimeoutSchedulerTest.java b/src/test/java/com/saferoute/domain/training/scheduler/TrainingTimeoutSchedulerTest.java new file mode 100644 index 00000000..71f13b77 --- /dev/null +++ b/src/test/java/com/saferoute/domain/training/scheduler/TrainingTimeoutSchedulerTest.java @@ -0,0 +1,30 @@ +package com.saferoute.domain.training.scheduler; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.saferoute.domain.training.service.TrainingSessionService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TrainingTimeoutSchedulerTest { + + @InjectMocks + private TrainingTimeoutScheduler scheduler; + + @Mock + private TrainingSessionService trainingSessionService; + + @Test + @DisplayName("스캔 주기마다 타임아웃 세션 처리를 위임한다") + void scanTimedOutSessions_delegatesToService() { + scheduler.scanTimedOutSessions(); + + verify(trainingSessionService, times(1)).failTimedOutSessions(); + } +} diff --git a/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java b/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java new file mode 100644 index 00000000..dd530893 --- /dev/null +++ b/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java @@ -0,0 +1,187 @@ +package com.saferoute.domain.training.service; + +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.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.saferoute.domain.training.entity.TrainingSession; +import com.saferoute.domain.training.entity.TrainingStatus; +import com.saferoute.domain.training.entity.TrainingScenario; +import com.saferoute.domain.training.repository.TrainingScenarioRepository; +import com.saferoute.domain.training.repository.TrainingSessionRepository; +import com.saferoute.domain.user.entity.User; +import com.saferoute.domain.user.repository.UserRepository; +import com.saferoute.global.api.error.TrainingErrorCode; +import com.saferoute.global.api.exception.ApiException; +import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class TrainingSessionServiceTest { + + @InjectMocks + private TrainingSessionService trainingSessionService; + + @Mock + private UserRepository userRepository; + + @Mock + private TrainingSessionRepository trainingSessionRepository; + + @Mock + private TrainingScenarioRepository trainingScenarioRepository; + + @Mock + private TrainingEventPublisher trainingEventPublisher; + + private final UUID sessionId = UUID.randomUUID(); + + private TrainingSession sessionWithStatus(TrainingStatus status) { + TrainingSession session = + TrainingSession.create(status, Instant.now(), mock(User.class), mock(TrainingScenario.class)); + ReflectionTestUtils.setField(session, "id", sessionId); + return session; + } + + // === start === + + @Test + @DisplayName("SCHEDULED 상태의 세션을 시작하면 RUNNING으로 전이하고 이벤트를 발행한다") + void start_fromScheduled_transitionsToRunningAndPublishesEvent() { + TrainingSession session = sessionWithStatus(TrainingStatus.SCHEDULED); + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + trainingSessionService.start(sessionId); + + assertThat(session.getStatus()).isEqualTo(TrainingStatus.RUNNING); + verify(trainingEventPublisher, times(1)).publishTrainingStatusUpdatedAfterCommit(session); + } + + @Test + @DisplayName("이미 RUNNING인 세션은 다시 시작할 수 없다") + void start_alreadyRunning_throwsException() { + TrainingSession session = sessionWithStatus(TrainingStatus.RUNNING); + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + assertThatThrownBy(() -> trainingSessionService.start(sessionId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(TrainingErrorCode.INVALID_STATUS_TRANSITION); + verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); + } + + @Test + @DisplayName("존재하지 않는 세션을 시작하려 하면 예외가 발생한다") + void start_sessionNotFound_throwsException() { + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> trainingSessionService.start(sessionId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(TrainingErrorCode.TRAINING_SESSION_NOT_FOUND); + } + + // === end === + + @Test + @DisplayName("RUNNING 상태의 세션을 정상 종료하면 COMPLETED로 전이하고 이벤트를 발행한다") + void end_fromRunning_transitionsToCompletedAndPublishesEvent() { + TrainingSession session = sessionWithStatus(TrainingStatus.RUNNING); + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + trainingSessionService.end(sessionId); + + assertThat(session.getStatus()).isEqualTo(TrainingStatus.COMPLETED); + assertThat(session.getEndedAt()).isNotNull(); + verify(trainingEventPublisher, times(1)).publishTrainingStatusUpdatedAfterCommit(session); + } + + @Test + @DisplayName("이미 종료된 훈련은 정상 종료할 수 없다") + void end_alreadyCompleted_throwsException() { + TrainingSession session = sessionWithStatus(TrainingStatus.COMPLETED); + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + assertThatThrownBy(() -> trainingSessionService.end(sessionId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(TrainingErrorCode.INVALID_STATUS_TRANSITION); + verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); + } + + // === forceEnd === + + @Test + @DisplayName("RUNNING 상태의 세션을 강제 종료하면 STOPPED로 전이하고 이벤트를 발행한다") + void forceEnd_fromRunning_transitionsToStoppedAndPublishesEvent() { + TrainingSession session = sessionWithStatus(TrainingStatus.RUNNING); + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + trainingSessionService.forceEnd(sessionId); + + assertThat(session.getStatus()).isEqualTo(TrainingStatus.STOPPED); + verify(trainingEventPublisher, times(1)).publishTrainingStatusUpdatedAfterCommit(session); + } + + @Test + @DisplayName("이미 종료된 훈련은 강제종료할 수 없다") + void forceEnd_alreadyStopped_throwsException() { + TrainingSession session = sessionWithStatus(TrainingStatus.STOPPED); + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.of(session)); + + assertThatThrownBy(() -> trainingSessionService.forceEnd(sessionId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(TrainingErrorCode.INVALID_STATUS_TRANSITION); + verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); + } + + // === failTimedOutSessions === + + @Test + @DisplayName("10분 타임아웃을 넘긴 RUNNING 세션을 FAILED로 처리하고 이벤트를 발행한다") + void failTimedOutSessions_marksExpiredSessionsAsFailed() { + TrainingSession timedOut = TrainingSession.create( + TrainingStatus.RUNNING, + Instant.now().minus(11, ChronoUnit.MINUTES), + mock(User.class), + mock(TrainingScenario.class)); + ReflectionTestUtils.setField(timedOut, "id", sessionId); + + given(trainingSessionRepository.findByStatusAndStartedAtBefore(any(), any())) + .willReturn(List.of(timedOut)); + + trainingSessionService.failTimedOutSessions(); + + assertThat(timedOut.getStatus()).isEqualTo(TrainingStatus.FAILED); + verify(trainingEventPublisher, times(1)).publishTrainingStatusUpdatedAfterCommit(timedOut); + } + + @Test + @DisplayName("타임아웃된 세션이 없으면 아무 것도 하지 않는다") + void failTimedOutSessions_noExpiredSessions_doesNothing() { + given(trainingSessionRepository.findByStatusAndStartedAtBefore(any(), any())) + .willReturn(List.of()); + + trainingSessionService.failTimedOutSessions(); + + verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); + } +} diff --git a/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java b/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java index 68d6d2ed..44181fcd 100644 --- a/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java +++ b/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java @@ -73,6 +73,9 @@ class WebSocketIntegrationTest { @Autowired private TrainingEventPublisher trainingEventPublisher; + @Autowired + private com.saferoute.domain.training.service.TrainingSessionService trainingSessionService; + @Autowired private ObjectMapper objectMapper; @@ -178,6 +181,97 @@ public void handleFrame(StompHeaders headers, Object payload) { session.disconnect(); } + @Test + @DisplayName("훈련을 시작하면 구독자가 TRAINING_STATUS_UPDATED(RUNNING) 이벤트를 수신한다") + void startingTrainingPublishesRunningEvent() throws Exception { + TrainingSession scheduledSession = TrainingSession.create( + TrainingStatus.SCHEDULED, Instant.now(), managerUser, trainingScenario); + trainingSessionRepository.save(scheduledSession); + + try { + StompSession session = connect(managerToken); + BlockingQueue received = subscribeAndCollect(session, scheduledSession.getId()); + + trainingSessionService.start(scheduledSession.getId()); + + JsonNode json = objectMapper.readTree(received.poll(5, TimeUnit.SECONDS)); + assertThat(json.get("eventType").asText()).isEqualTo("TRAINING_STATUS_UPDATED"); + assertThat(json.get("data").get("status").asText()).isEqualTo("RUNNING"); + + session.disconnect(); + } finally { + trainingSessionRepository.delete(scheduledSession); + } + } + + @Test + @DisplayName("훈련을 정상 종료하면 구독자가 TRAINING_STATUS_UPDATED(COMPLETED) 이벤트를 수신한다") + void endingTrainingPublishesCompletedEvent() throws Exception { + TrainingSession runningSession = TrainingSession.create( + TrainingStatus.RUNNING, Instant.now(), managerUser, trainingScenario); + trainingSessionRepository.save(runningSession); + + try { + StompSession session = connect(managerToken); + BlockingQueue received = subscribeAndCollect(session, runningSession.getId()); + + trainingSessionService.end(runningSession.getId()); + + JsonNode json = objectMapper.readTree(received.poll(5, TimeUnit.SECONDS)); + assertThat(json.get("data").get("status").asText()).isEqualTo("COMPLETED"); + + session.disconnect(); + } finally { + trainingSessionRepository.delete(runningSession); + } + } + + @Test + @DisplayName("훈련을 강제 종료하면 구독자가 TRAINING_STATUS_UPDATED(STOPPED) 이벤트를 수신한다") + void forceEndingTrainingPublishesStoppedEvent() throws Exception { + TrainingSession runningSession = TrainingSession.create( + TrainingStatus.RUNNING, Instant.now(), managerUser, trainingScenario); + trainingSessionRepository.save(runningSession); + + try { + StompSession session = connect(managerToken); + BlockingQueue received = subscribeAndCollect(session, runningSession.getId()); + + trainingSessionService.forceEnd(runningSession.getId()); + + JsonNode json = objectMapper.readTree(received.poll(5, TimeUnit.SECONDS)); + assertThat(json.get("data").get("status").asText()).isEqualTo("STOPPED"); + + session.disconnect(); + } finally { + trainingSessionRepository.delete(runningSession); + } + } + + private BlockingQueue subscribeAndCollect(StompSession session, java.util.UUID sessionId) + throws InterruptedException { + BlockingQueue received = new LinkedBlockingQueue<>(); + + session.subscribe( + "/topic/training-sessions/" + sessionId, + new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return byte[].class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + received.add(new String((byte[]) payload)); + } + } + ); + + // 구독이 브로커에 등록될 시간을 확보한다. + Thread.sleep(300); + return received; + } + @Test @DisplayName("Authorization 헤더 없이 연결하면 실패한다") void connectWithoutTokenFails() { From ce155d7a2a860222e517324fd87b218bcc967362 Mon Sep 17 00:00:00 2001 From: PHJ Date: Tue, 4 Aug 2026 20:51:45 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(training):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9D=91=EB=8B=B5=EC=97=90=20id=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20-=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrainingSessionResponse에 세션 UUID(id)가 빠져 있어, 세션 생성 후 start/end/force-end 호출에 필요한 sessionId를 응답에서 알 수 없던 문제 수정. 웹소켓 수동 테스트용 test.html은 더 이상 필요 없어 삭제. --- .../training/dto/TrainingSessionResponse.java | 3 + src/main/resources/static/test.html | 124 ------------------ 2 files changed, 3 insertions(+), 124 deletions(-) delete mode 100644 src/main/resources/static/test.html diff --git a/src/main/java/com/saferoute/domain/training/dto/TrainingSessionResponse.java b/src/main/java/com/saferoute/domain/training/dto/TrainingSessionResponse.java index 4e223b1a..1890fe87 100644 --- a/src/main/java/com/saferoute/domain/training/dto/TrainingSessionResponse.java +++ b/src/main/java/com/saferoute/domain/training/dto/TrainingSessionResponse.java @@ -3,12 +3,14 @@ import com.saferoute.domain.training.entity.TrainingStatus; import com.saferoute.domain.training.entity.TrainingSession; import java.time.Instant; +import java.util.UUID; import lombok.Builder; import lombok.Getter; @Getter @Builder public class TrainingSessionResponse { + private UUID id; private TrainingStatus status; private Instant startedAt; private String adminName; @@ -16,6 +18,7 @@ public class TrainingSessionResponse { public static TrainingSessionResponse from(TrainingSession session) { return TrainingSessionResponse.builder() + .id(session.getId()) .status(session.getStatus()) .startedAt(session.getStartedAt()) .adminName(session.getAdmin().getUsername()) diff --git a/src/main/resources/static/test.html b/src/main/resources/static/test.html deleted file mode 100644 index afcf6b55..00000000 --- a/src/main/resources/static/test.html +++ /dev/null @@ -1,124 +0,0 @@ - - - - - SafeRoute WebSocket 테스트 (오프라인 동작 - CDN 미사용) - - - - - -

SafeRoute WebSocket 테스트

-

stomp.js가 이 파일 안에 내장되어 있어 외부 CDN 접속 없이 동작합니다.

- - - - - - - - - - - - - -
상태: 대기 중
-
- - - - - \ No newline at end of file From 287f4e28d9017a9c3b26adaa2271b7898af030b6 Mon Sep 17 00:00:00 2001 From: PHJ Date: Tue, 4 Aug 2026 21:09:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(training):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=99=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EC=A0=84?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=A9=EC=A7=80=20-=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create()에서 관리자 역할이 MANAGER가 아니면 FORBIDDEN 예외를 던지도록 검증 추가 - TrainingSession에 @Version을 추가해 start/end/force-end/timeout 간 동시 상태 전이 시 하나만 성공하고 나머지는 낙관적 락 충돌로 실패하도록 변경 - ObjectOptimisticLockingFailureException을 409 CONFLICT로 매핑하는 전역 예외 핸들러 추가 - end/forceEnd의 세션 없음 케이스 테스트 추가 - WebSocketIntegrationTest에서 서비스가 갱신한 stale 엔티티를 delete()하며 발생하던 낙관적 락 충돌을 deleteById()로 수정 --- .../training/entity/TrainingSession.java | 3 +++ .../service/TrainingSessionService.java | 5 ++++ .../api/exception/GlobalExceptionHandler.java | 6 +++++ .../service/TrainingSessionServiceTest.java | 24 +++++++++++++++++++ .../service/WebSocketIntegrationTest.java | 8 +++---- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java b/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java index 33ab45dc..85ffdadd 100644 --- a/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java +++ b/src/main/java/com/saferoute/domain/training/entity/TrainingSession.java @@ -26,6 +26,9 @@ public class TrainingSession { @GeneratedValue private UUID id; + @Version + private Long version; + //훈련 상태 (RUNNING, STOPPED 등) @NotNull @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java b/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java index e3b504c1..5435a9bb 100644 --- a/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java +++ b/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java @@ -12,7 +12,9 @@ import com.saferoute.domain.training.repository.TrainingScenarioRepository; import com.saferoute.domain.training.repository.TrainingSessionRepository; import com.saferoute.domain.user.entity.User; +import com.saferoute.domain.user.entity.UserRole; import com.saferoute.domain.user.repository.UserRepository; +import com.saferoute.global.api.code.ErrorCode; import com.saferoute.global.api.error.TrainingErrorCode; import com.saferoute.global.api.exception.ApiException; import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher; @@ -43,6 +45,9 @@ public class TrainingSessionService { public TrainingSessionResponse create(CreateSessionRequest request, UUID scenarioId) { User user = userRepository.findById(request.getAdminId()) .orElseThrow(() -> new ApiException(TrainingErrorCode.ADMIN_NOT_FOUND)); + if (user.getRole() != UserRole.MANAGER) { + throw new ApiException(ErrorCode.FORBIDDEN); + } TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId) .orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SCENARIO_NOT_FOUND)); diff --git a/src/main/java/com/saferoute/global/api/exception/GlobalExceptionHandler.java b/src/main/java/com/saferoute/global/api/exception/GlobalExceptionHandler.java index 72f284af..a1ad7fad 100644 --- a/src/main/java/com/saferoute/global/api/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/saferoute/global/api/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -28,6 +29,11 @@ public ResponseEntity> handleApiException(ApiException excepti return response(exception.getErrorCode(), null); } + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity> handleOptimisticLockingFailure() { + return response(ErrorCode.CONFLICT, null); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity>> handleValidation( MethodArgumentNotValidException exception diff --git a/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java b/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java index dd530893..59c2c97c 100644 --- a/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java +++ b/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java @@ -126,6 +126,18 @@ void end_alreadyCompleted_throwsException() { verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); } + @Test + @DisplayName("존재하지 않는 세션을 정상 종료하려 하면 예외가 발생한다") + void end_sessionNotFound_throwsException() { + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> trainingSessionService.end(sessionId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(TrainingErrorCode.TRAINING_SESSION_NOT_FOUND); + verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); + } + // === forceEnd === @Test @@ -153,6 +165,18 @@ void forceEnd_alreadyStopped_throwsException() { verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); } + @Test + @DisplayName("존재하지 않는 세션을 강제종료하려 하면 예외가 발생한다") + void forceEnd_sessionNotFound_throwsException() { + given(trainingSessionRepository.findById(sessionId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> trainingSessionService.forceEnd(sessionId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(TrainingErrorCode.TRAINING_SESSION_NOT_FOUND); + verify(trainingEventPublisher, never()).publishTrainingStatusUpdatedAfterCommit(any()); + } + // === failTimedOutSessions === @Test diff --git a/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java b/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java index 44181fcd..404a4bf4 100644 --- a/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java +++ b/src/test/java/com/saferoute/infrastructure/websocket/service/WebSocketIntegrationTest.java @@ -137,7 +137,7 @@ void tearDown() { stompClient.stop(); // @Transactional을 쓰지 않으므로 테스트가 만든 데이터를 직접 정리한다. - trainingSessionRepository.delete(trainingSession); + trainingSessionRepository.deleteById(trainingSession.getId()); trainingScenarioRepository.delete(trainingScenario); buildingRepository.delete(building); userRepository.delete(managerUser); @@ -200,7 +200,7 @@ void startingTrainingPublishesRunningEvent() throws Exception { session.disconnect(); } finally { - trainingSessionRepository.delete(scheduledSession); + trainingSessionRepository.deleteById(scheduledSession.getId()); } } @@ -222,7 +222,7 @@ void endingTrainingPublishesCompletedEvent() throws Exception { session.disconnect(); } finally { - trainingSessionRepository.delete(runningSession); + trainingSessionRepository.deleteById(runningSession.getId()); } } @@ -244,7 +244,7 @@ void forceEndingTrainingPublishesStoppedEvent() throws Exception { session.disconnect(); } finally { - trainingSessionRepository.delete(runningSession); + trainingSessionRepository.deleteById(runningSession.getId()); } } From aaf190d1a70dd7a6295cecfa8e440cf6979034bf Mon Sep 17 00:00:00 2001 From: PHJ Date: Tue, 4 Aug 2026 21:34:48 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(training):=20RUNNING=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=81=20=EB=88=84=EB=9D=BD=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20-=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨트롤러가 @Valid 없이 CreateSessionRequest를 받아 startedAt의 @NotNull이 실제로는 적용되지 않던 문제. RUNNING 상태로 생성 요청 시 startedAt이 null이면 INVALID_INPUT을 던지도록 서비스 레이어에 검증 추가. --- .../service/TrainingSessionService.java | 4 +++ .../service/TrainingSessionServiceTest.java | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java b/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java index 5435a9bb..70f01443 100644 --- a/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java +++ b/src/main/java/com/saferoute/domain/training/service/TrainingSessionService.java @@ -51,6 +51,10 @@ public TrainingSessionResponse create(CreateSessionRequest request, UUID scenari TrainingScenario scenario = trainingScenarioRepository.findById(scenarioId) .orElseThrow(() -> new ApiException(TrainingErrorCode.TRAINING_SCENARIO_NOT_FOUND)); + if (request.getStatus() == TrainingStatus.RUNNING && request.getStartedAt() == null) { + throw new ApiException(ErrorCode.INVALID_INPUT); + } + TrainingSession trainingSession = TrainingSession.create( request.getStatus(), request.getStartedAt(), diff --git a/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java b/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java index 59c2c97c..360dbfcb 100644 --- a/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java +++ b/src/test/java/com/saferoute/domain/training/service/TrainingSessionServiceTest.java @@ -9,13 +9,16 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import com.saferoute.domain.training.dto.CreateSessionRequest; import com.saferoute.domain.training.entity.TrainingSession; import com.saferoute.domain.training.entity.TrainingStatus; import com.saferoute.domain.training.entity.TrainingScenario; import com.saferoute.domain.training.repository.TrainingScenarioRepository; import com.saferoute.domain.training.repository.TrainingSessionRepository; import com.saferoute.domain.user.entity.User; +import com.saferoute.domain.user.entity.UserRole; import com.saferoute.domain.user.repository.UserRepository; +import com.saferoute.global.api.code.ErrorCode; import com.saferoute.global.api.error.TrainingErrorCode; import com.saferoute.global.api.exception.ApiException; import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher; @@ -60,6 +63,28 @@ private TrainingSession sessionWithStatus(TrainingStatus status) { return session; } + // === create === + + @Test + @DisplayName("RUNNING 상태로 세션을 생성할 때 시작 시각이 없으면 예외가 발생한다") + void create_runningWithoutStartedAt_throwsException() { + UUID adminId = UUID.randomUUID(); + UUID scenarioId = UUID.randomUUID(); + + User manager = mock(User.class); + given(manager.getRole()).willReturn(UserRole.MANAGER); + given(userRepository.findById(adminId)).willReturn(Optional.of(manager)); + given(trainingScenarioRepository.findById(scenarioId)) + .willReturn(Optional.of(mock(TrainingScenario.class))); + + CreateSessionRequest request = new CreateSessionRequest(TrainingStatus.RUNNING, null, adminId); + + assertThatThrownBy(() -> trainingSessionService.create(request, scenarioId)) + .isInstanceOf(ApiException.class) + .extracting(exception -> ((ApiException) exception).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT); + } + // === start === @Test