diff --git a/back/src/main/java/com/back/domain/scenario/controller/ScenarioController.java b/back/src/main/java/com/back/domain/scenario/controller/ScenarioController.java index b12e6e7..a0eafd1 100644 --- a/back/src/main/java/com/back/domain/scenario/controller/ScenarioController.java +++ b/back/src/main/java/com/back/domain/scenario/controller/ScenarioController.java @@ -26,15 +26,15 @@ @RequiredArgsConstructor public class ScenarioController { + // TODO: ApiResponse를 ResponseEntity로 변경 예정 private final ScenarioService scenarioService; @PostMapping @Operation(summary = "시나리오 생성", description = "DecisionLine을 기반으로 AI 시나리오를 생성합니다.") public ApiResponse createScenario( - @Valid @RequestBody ScenarioCreateRequest request, - Principal principal + @Valid @RequestBody ScenarioCreateRequest request ) { - Long userId = getUserIdFromPrincipal(principal); + Long userId = 1L; // TODO: Principal에서 추출 예정 ScenarioStatusResponse scenarioCreateResponse = scenarioService.createScenario(userId, request); @@ -44,10 +44,9 @@ public ApiResponse createScenario( @GetMapping("/{scenarioId}/status") @Operation(summary = "시나리오 상태 조회", description = "시나리오 생성 진행 상태를 조회합니다.") public ApiResponse getScenarioStatus( - @Parameter(description = "시나리오 ID") @PathVariable Long scenarioId, - Principal principal + @Parameter(description = "시나리오 ID") @PathVariable Long scenarioId ) { - Long userId = getUserIdFromPrincipal(principal); + Long userId = 1L; // TODO: Principal에서 추출 예정 ScenarioStatusResponse scenarioStatusResponse = scenarioService.getScenarioStatus(scenarioId, userId); @@ -57,10 +56,9 @@ public ApiResponse getScenarioStatus( @GetMapping("/info/{scenarioId}") @Operation(summary = "시나리오 상세 조회", description = "완성된 시나리오의 상세 정보를 조회합니다.") public ApiResponse getScenarioDetail( - @Parameter(description = "시나리오 ID") @PathVariable Long scenarioId, - Principal principal + @Parameter(description = "시나리오 ID") @PathVariable Long scenarioId ) { - Long userId = getUserIdFromPrincipal(principal); + Long userId = 1L; // TODO: Principal에서 추출 예정 ScenarioDetailResponse scenarioDetailResponse = scenarioService.getScenarioDetail(scenarioId, userId); @@ -70,10 +68,9 @@ public ApiResponse getScenarioDetail( @GetMapping("/{scenarioId}/timeline") @Operation(summary = "시나리오 타임라인 조회", description = "시나리오의 선택 경로를 시간순으로 조회합니다.") public ApiResponse getScenarioTimeline( - @Parameter(description = "시나리오 ID") @PathVariable Long scenarioId, - Principal principal + @Parameter(description = "시나리오 ID") @PathVariable Long scenarioId ) { - Long userId = getUserIdFromPrincipal(principal); + Long userId = 1L; // TODO: Principal에서 추출 예정 TimelineResponse timelineResponse = scenarioService.getScenarioTimeline(scenarioId, userId); @@ -82,10 +79,8 @@ public ApiResponse getScenarioTimeline( @GetMapping("/baselines") @Operation(summary = "베이스라인 목록 조회", description = "사용자의 베이스라인 목록을 조회합니다.") - public ApiResponse> getBaselines( - Principal principal - ) { - Long userId = getUserIdFromPrincipal(principal); + public ApiResponse> getBaselines() { + Long userId = 1L; // TODO: Principal에서 추출 예정 List baselines = scenarioService.getBaselines(userId); @@ -96,10 +91,9 @@ public ApiResponse> getBaselines( @Operation(summary = "시나리오 비교 분석 결과 조회", description = "두 시나리오를 비교 분석 결과를 조회합니다.") public ApiResponse compareScenarios( @Parameter(description = "기준 시나리오 ID") @PathVariable Long baseId, - @Parameter(description = "비교 시나리오 ID") @PathVariable Long compareId, - Principal principal + @Parameter(description = "비교 시나리오 ID") @PathVariable Long compareId ) { - Long userId = getUserIdFromPrincipal(principal); + Long userId = 1L; // TODO: Principal에서 추출 예정 ScenarioCompareResponse scenarioCompareResponse = scenarioService.compareScenarios(baseId, compareId, userId); diff --git a/back/src/test/java/com/back/domain/scenario/controller/ScenarioControllerTest.java b/back/src/test/java/com/back/domain/scenario/controller/ScenarioControllerTest.java new file mode 100644 index 0000000..e8302ea --- /dev/null +++ b/back/src/test/java/com/back/domain/scenario/controller/ScenarioControllerTest.java @@ -0,0 +1,312 @@ +package com.back.domain.scenario.controller; + +import com.back.domain.scenario.dto.*; +import com.back.domain.scenario.entity.ScenarioStatus; +import com.back.domain.scenario.entity.Type; +import com.back.domain.scenario.service.ScenarioService; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +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.junit.jupiter.api.TestInstance; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * ScenarioController 통합 테스트. + * 인증/인가가 구현되지 않은 상태에서 Service를 모킹하여 테스트합니다. + */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("ScenarioController 통합 테스트") +class ScenarioControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private ScenarioService scenarioService; + + @Nested + @DisplayName("시나리오 생성") + class CreateScenario { + + @Test + @DisplayName("성공 - 정상적인 시나리오 생성 요청") + void createScenario_성공() throws Exception { + // Given + Long decisionLineId = 100L; + ScenarioCreateRequest request = new ScenarioCreateRequest(decisionLineId); + + ScenarioStatusResponse mockResponse = new ScenarioStatusResponse( + 1001L, + ScenarioStatus.PENDING, + "시나리오 생성이 시작되었습니다." + ); + + given(scenarioService.createScenario(eq(1L), any(ScenarioCreateRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(post("/api/v1/scenarios") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) // ApiResponse는 실제 HTTP 상태를 설정하지 않음 + .andExpect(jsonPath("$.data.scenarioId").value(1001)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.message").value("시나리오 생성 요청이 접수되었습니다.")) + .andExpect(jsonPath("$.status").value(201)); // 응답 본문의 status 필드 검증 + } + + @Test + @DisplayName("실패 - 잘못된 요청 데이터 (null decisionLineId)") + void createScenario_실패_잘못된요청() throws Exception { + // Given + String invalidRequest = "{\"decisionLineId\":null}"; + + // When & Then + mockMvc.perform(post("/api/v1/scenarios") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패 - Service 예외 발생") + void createScenario_실패_Service예외() throws Exception { + // Given + ScenarioCreateRequest request = new ScenarioCreateRequest(999L); + + given(scenarioService.createScenario(eq(1L), any(ScenarioCreateRequest.class))) + .willThrow(new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND)); + + // When & Then + mockMvc.perform(post("/api/v1/scenarios") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("시나리오 상태 조회") + class GetScenarioStatus { + + @Test + @DisplayName("성공 - 유효한 시나리오 상태 조회") + void getScenarioStatus_성공() throws Exception { + // Given + Long scenarioId = 1001L; + ScenarioStatusResponse mockResponse = new ScenarioStatusResponse( + scenarioId, + ScenarioStatus.COMPLETED, + "시나리오 생성이 완료되었습니다." + ); + + given(scenarioService.getScenarioStatus(scenarioId, 1L)) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/v1/scenarios/{scenarioId}/status", scenarioId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scenarioId").value(scenarioId)) + .andExpect(jsonPath("$.data.status").value("COMPLETED")) + .andExpect(jsonPath("$.message").value("상태를 성공적으로 조회했습니다.")); + } + + @Test + @DisplayName("실패 - 존재하지 않는 시나리오") + void getScenarioStatus_실패_없는시나리오() throws Exception { + // Given + Long scenarioId = 999L; + + given(scenarioService.getScenarioStatus(scenarioId, 1L)) + .willThrow(new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); + + // When & Then + mockMvc.perform(get("/api/v1/scenarios/{scenarioId}/status", scenarioId)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("시나리오 상세 조회") + class GetScenarioDetail { + + @Test + @DisplayName("성공 - 완료된 시나리오 상세 조회") + void getScenarioDetail_성공() throws Exception { + // Given + Long scenarioId = 1001L; + + List indicators = List.of( + new ScenarioTypeDto(Type.경제, 90, "창업 성공으로 높은 경제적 성취"), + new ScenarioTypeDto(Type.행복, 85, "자신이 원하는 일을 하며 성취감 높음"), + new ScenarioTypeDto(Type.관계, 75, "업무로 인해 개인 관계에 다소 소홀"), + new ScenarioTypeDto(Type.직업, 95, "창업가로서 최고 수준의 직업 만족도"), + new ScenarioTypeDto(Type.건강, 70, "스트레스로 인한 건강 관리 필요") + ); + + ScenarioDetailResponse mockResponse = new ScenarioDetailResponse( + scenarioId, + ScenarioStatus.COMPLETED, + "스타트업 CEO", + 85, + "성공적인 창업으로 안정적인 수익 창출", + "창업 초기 어려움을 극복하고 지속가능한 비즈니스 모델을 구축했습니다.", + "https://example.com/scenario-image.jpg", + LocalDateTime.now().minusDays(1), + indicators + ); + + given(scenarioService.getScenarioDetail(scenarioId, 1L)) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/v1/scenarios/info/{scenarioId}", scenarioId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scenarioId").value(scenarioId)) + .andExpect(jsonPath("$.data.job").value("스타트업 CEO")) + .andExpect(jsonPath("$.data.total").value(85)) + .andExpect(jsonPath("$.data.indicators").isArray()) + .andExpect(jsonPath("$.data.indicators.length()").value(5)); + } + } + + @Nested + @DisplayName("시나리오 타임라인 조회") + class GetScenarioTimeline { + + @Test + @DisplayName("성공 - 시나리오 타임라인 조회") + void getScenarioTimeline_성공() throws Exception { + // Given + Long scenarioId = 1001L; + + List events = List.of( + new TimelineResponse.TimelineEvent(2025, "창업 시작"), + new TimelineResponse.TimelineEvent(2027, "첫 투자 유치"), + new TimelineResponse.TimelineEvent(2030, "IPO 성공") + ); + + TimelineResponse mockResponse = new TimelineResponse(scenarioId, events); + + given(scenarioService.getScenarioTimeline(scenarioId, 1L)) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/v1/scenarios/{scenarioId}/timeline", scenarioId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.scenarioId").value(scenarioId)) + .andExpect(jsonPath("$.data.events").isArray()) + .andExpect(jsonPath("$.data.events[0].year").value(2025)) + .andExpect(jsonPath("$.data.events[0].title").value("창업 시작")); + } + } + + @Nested + @DisplayName("베이스라인 목록 조회") + class GetBaselines { + + @Test + @DisplayName("성공 - 사용자 베이스라인 목록 조회") + void getBaselines_성공() throws Exception { + // Given + List mockResponse = List.of( + new BaselineListResponse( + 200L, + "대학 졸업 이후", + List.of("진로", "교육"), + LocalDateTime.now().minusMonths(6) + ), + new BaselineListResponse( + 201L, + "첫 직장 입사", + List.of("직업", "성장"), + LocalDateTime.now().minusMonths(3) + ) + ); + + given(scenarioService.getBaselines(1L)) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/v1/scenarios/baselines")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].baselineId").value(200)) + .andExpect(jsonPath("$.data[0].title").value("대학 졸업 이후")); + } + } + + @Nested + @DisplayName("시나리오 비교") + class CompareScenarios { + + @Test + @DisplayName("성공 - 시나리오 비교 분석") + void compareScenarios_성공() throws Exception { + // Given + Long baseId = 1001L; + Long compareId = 1002L; + + List indicators = List.of( + new ScenarioCompareResponse.IndicatorComparison(Type.경제, 90, 80, "창업이 대기업보다 경제적으로 유리"), + new ScenarioCompareResponse.IndicatorComparison(Type.행복, 85, 70, "창업을 통한 더 높은 성취감"), + new ScenarioCompareResponse.IndicatorComparison(Type.관계, 75, 85, "대기업에서 더 안정적인 인간관계"), + new ScenarioCompareResponse.IndicatorComparison(Type.직업, 95, 75, "창업가로서 더 높은 직업 만족도"), + new ScenarioCompareResponse.IndicatorComparison(Type.건강, 70, 80, "대기업에서 더 나은 워라밸") + ); + + ScenarioCompareResponse mockResponse = new ScenarioCompareResponse( + baseId, + compareId, + "창업 경로가 전반적으로 더 도전적이지만 성취감과 경제적 보상이 큽니다.", + indicators + ); + + given(scenarioService.compareScenarios(baseId, compareId, 1L)) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/v1/scenarios/compare/{baseId}/{compareId}", baseId, compareId)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.baseScenarioId").value(baseId)) + .andExpect(jsonPath("$.data.compareScenarioId").value(compareId)) + .andExpect(jsonPath("$.data.overallAnalysis").exists()) + .andExpect(jsonPath("$.data.indicators").isArray()); + } + } +} \ No newline at end of file diff --git a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java new file mode 100644 index 0000000..c24bac1 --- /dev/null +++ b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java @@ -0,0 +1,430 @@ +package com.back.domain.scenario.service; + +import com.back.domain.node.entity.BaseLine; +import com.back.domain.node.entity.DecisionLine; +import com.back.domain.node.repository.BaseLineRepository; +import com.back.domain.node.repository.DecisionLineRepository; +import com.back.domain.scenario.dto.ScenarioCreateRequest; +import com.back.domain.scenario.dto.ScenarioStatusResponse; +import com.back.domain.scenario.entity.Scenario; +import com.back.domain.scenario.entity.ScenarioStatus; +import com.back.domain.scenario.repository.ScenarioRepository; +import com.back.domain.scenario.repository.SceneCompareRepository; +import com.back.domain.scenario.repository.SceneTypeRepository; +import com.back.domain.user.entity.User; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import com.fasterxml.jackson.core.type.TypeReference; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +/** + * ScenarioService 단위 테스트. + * 비즈니스 로직의 핵심 기능들을 검증합니다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ScenarioService 단위 테스트") +class ScenarioServiceTest { + + @Mock private ScenarioRepository scenarioRepository; + @Mock private SceneTypeRepository sceneTypeRepository; + @Mock private SceneCompareRepository sceneCompareRepository; + @Mock private DecisionLineRepository decisionLineRepository; + @Mock private BaseLineRepository baseLineRepository; + @Mock private ObjectMapper objectMapper; + + @Spy @InjectMocks private ScenarioService scenarioService; + + @Nested + @DisplayName("시나리오 생성") + class CreateScenarioTests { + + @Test + @DisplayName("성공 - 시나리오 생성 요청 접수") + void createScenario_성공_시나리오생성요청접수() { + // Given + Long userId = 1L; + Long decisionLineId = 100L; + ScenarioCreateRequest request = new ScenarioCreateRequest(decisionLineId); + + User mockUser = User.builder().build(); + ReflectionTestUtils.setField(mockUser, "id", userId); + + BaseLine mockBaseLine = BaseLine.builder().user(mockUser).build(); + ReflectionTestUtils.setField(mockBaseLine, "id", 200L); + + DecisionLine mockDecisionLine = DecisionLine.builder() + .user(mockUser) + .baseLine(mockBaseLine) + .build(); + ReflectionTestUtils.setField(mockDecisionLine, "id", decisionLineId); + + Scenario savedScenario = Scenario.builder() + .user(mockUser) + .decisionLine(mockDecisionLine) + .status(ScenarioStatus.PENDING) + .build(); + ReflectionTestUtils.setField(savedScenario, "id", 1001L); + + // 중요: 비동기 메서드에서 호출되는 repository.findById()도 모킹 필요 + given(decisionLineRepository.findById(decisionLineId)) + .willReturn(Optional.of(mockDecisionLine)); + given(scenarioRepository.existsByDecisionLineIdAndStatus(decisionLineId, ScenarioStatus.PENDING)) + .willReturn(false); + given(scenarioRepository.existsByDecisionLineIdAndStatus(decisionLineId, ScenarioStatus.PROCESSING)) + .willReturn(false); + given(scenarioRepository.existsByDecisionLine_BaseLineId(200L)) + .willReturn(true); // 베이스 시나리오 이미 존재 + given(scenarioRepository.save(any(Scenario.class))) + .willReturn(savedScenario); + + // 비동기 메서드 호출을 무효화 (동기 로직만 테스트) + doNothing().when(scenarioService).processScenarioGenerationAsync(anyLong()); + + // When + ScenarioStatusResponse result = scenarioService.createScenario(userId, request); + + // Then - 시나리오 생성 요청이 접수되고 PENDING 상태로 반환되는지만 검증 + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(1001L); + assertThat(result.status()).isEqualTo(ScenarioStatus.PENDING); + assertThat(result.message()).isEqualTo("시나리오 생성이 시작되었습니다."); + + // 동기 부분의 핵심 비즈니스 로직만 검증 + verify(decisionLineRepository).findById(decisionLineId); + verify(scenarioRepository).existsByDecisionLineIdAndStatus(decisionLineId, ScenarioStatus.PENDING); + verify(scenarioRepository).existsByDecisionLineIdAndStatus(decisionLineId, ScenarioStatus.PROCESSING); + verify(scenarioRepository).existsByDecisionLine_BaseLineId(200L); + verify(scenarioRepository).save(any(Scenario.class)); + + // 비동기 메서드가 호출되었는지 확인 (하지만 실행은 되지 않음) + verify(scenarioService).processScenarioGenerationAsync(1001L); + } + + @Test + @DisplayName("실패 - 존재하지 않는 DecisionLine") + void createScenario_실패_존재하지않는_DecisionLine() { + // Given + Long userId = 1L; + Long decisionLineId = 999L; + ScenarioCreateRequest request = new ScenarioCreateRequest(decisionLineId); + + given(decisionLineRepository.findById(decisionLineId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> scenarioService.createScenario(userId, request)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DECISION_LINE_NOT_FOUND); + + verify(decisionLineRepository).findById(decisionLineId); + verify(scenarioRepository, never()).save(any()); + } + + @Test + @DisplayName("실패 - 권한이 없는 사용자") + void createScenario_실패_권한없는_사용자() { + // Given + Long userId = 1L; + Long unauthorizedUserId = 999L; + Long decisionLineId = 100L; + ScenarioCreateRequest request = new ScenarioCreateRequest(decisionLineId); + + User unauthorizedUser = User.builder().build(); + ReflectionTestUtils.setField(unauthorizedUser, "id", unauthorizedUserId); + + DecisionLine mockDecisionLine = DecisionLine.builder() + .user(unauthorizedUser) // 다른 사용자 소유 + .build(); + ReflectionTestUtils.setField(mockDecisionLine, "id", decisionLineId); + + given(decisionLineRepository.findById(decisionLineId)) + .willReturn(Optional.of(mockDecisionLine)); + + // When & Then + assertThatThrownBy(() -> scenarioService.createScenario(userId, request)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.HANDLE_ACCESS_DENIED); + + verify(decisionLineRepository).findById(decisionLineId); + verify(scenarioRepository, never()).save(any()); + } + + @Test + @DisplayName("실패 - 이미 진행 중인 시나리오 존재") + void createScenario_실패_이미_진행중인_시나리오_존재() { + // Given + Long userId = 1L; + Long decisionLineId = 100L; + ScenarioCreateRequest request = new ScenarioCreateRequest(decisionLineId); + + User mockUser = User.builder().build(); + ReflectionTestUtils.setField(mockUser, "id", userId); + + DecisionLine mockDecisionLine = DecisionLine.builder() + .user(mockUser) + .build(); + ReflectionTestUtils.setField(mockDecisionLine, "id", decisionLineId); + + given(decisionLineRepository.findById(decisionLineId)) + .willReturn(Optional.of(mockDecisionLine)); + given(scenarioRepository.existsByDecisionLineIdAndStatus(decisionLineId, ScenarioStatus.PENDING)) + .willReturn(true); // 이미 PENDING 상태 시나리오 존재 + + // When & Then + assertThatThrownBy(() -> scenarioService.createScenario(userId, request)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_ALREADY_IN_PROGRESS); + + verify(scenarioRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("비동기 시나리오 생성 처리") + class ProcessScenarioGenerationAsyncTests { + + @Test + @DisplayName("성공 - 비동기 시나리오 생성 및 완료") + void processScenarioGenerationAsync_성공_시나리오생성완료() { + // Given + Long scenarioId = 1001L; + Scenario mockScenario = Scenario.builder() + .status(ScenarioStatus.PENDING) + .build(); + ReflectionTestUtils.setField(mockScenario, "id", scenarioId); + + given(scenarioRepository.findById(scenarioId)) + .willReturn(Optional.of(mockScenario)); + given(scenarioRepository.save(any(Scenario.class))) + .willReturn(mockScenario); + + // When + scenarioService.processScenarioGenerationAsync(scenarioId); + + // Then + // 비동기 메서드이므로 호출 여부만 검증 + verify(scenarioRepository, atLeast(1)).findById(scenarioId); + verify(scenarioRepository, atLeast(2)).save(any(Scenario.class)); // PROCESSING, COMPLETED 상태 저장 + } + + @Test + @DisplayName("실패 - 존재하지 않는 시나리오 (비동기 처리)") + void processScenarioGenerationAsync_실패_존재하지않는_시나리오() { + // Given + Long scenarioId = 999L; + given(scenarioRepository.findById(scenarioId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> scenarioService.processScenarioGenerationAsync(scenarioId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_NOT_FOUND); + + verify(scenarioRepository).findById(scenarioId); + verify(scenarioRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("시나리오 상태 조회") + class GetScenarioStatusTests { + + @Test + @DisplayName("성공 - 유효한 시나리오 상태 조회") + void getScenarioStatus_성공_유효한_시나리오_상태_조회() { + // Given + Long scenarioId = 1001L; + Long userId = 1L; + + Scenario mockScenario = Scenario.builder() + .status(ScenarioStatus.COMPLETED) + .build(); + ReflectionTestUtils.setField(mockScenario, "id", scenarioId); + + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(mockScenario)); + + // When + ScenarioStatusResponse result = scenarioService.getScenarioStatus(scenarioId, userId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.scenarioId()).isEqualTo(scenarioId); + assertThat(result.status()).isEqualTo(ScenarioStatus.COMPLETED); + assertThat(result.message()).isEqualTo("시나리오 생성이 완료되었습니다."); + + verify(scenarioRepository).findByIdAndUserId(scenarioId, userId); + } + + @Test + @DisplayName("실패 - 존재하지 않는 시나리오") + void getScenarioStatus_실패_존재하지않는_시나리오() { + // Given + Long scenarioId = 999L; + Long userId = 1L; + + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> scenarioService.getScenarioStatus(scenarioId, userId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_NOT_FOUND); + + verify(scenarioRepository).findByIdAndUserId(scenarioId, userId); + } + } + + @Nested + @DisplayName("JSON 파싱 로직") + class ParseTimelineTitlesTests { + + @Test + @DisplayName("성공 - 유효한 JSON 파싱") + void parseTimelineTitles_성공_유효한_JSON_파싱() throws Exception { + // Given + String validJson = "{\"2020\":\"창업 시작\",\"2025\":\"상장 성공\"}"; + Map expectedResult = Map.of("2020", "창업 시작", "2025", "상장 성공"); + + given(objectMapper.readValue(eq(validJson), any(TypeReference.class))) + .willReturn(expectedResult); + + // When + // parseTimelineTitles는 private이므로 reflection으로 테스트하거나 + // public 메서드를 통해 간접적으로 테스트 + // 여기서는 getScenarioTimeline을 통해 간접 테스트 + Long scenarioId = 1001L; + Long userId = 1L; + + Scenario mockScenario = Scenario.builder() + .timelineTitles(validJson) + .build(); + ReflectionTestUtils.setField(mockScenario, "id", scenarioId); + + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(mockScenario)); + + // Then + // parseTimelineTitles가 정상 동작하면 예외가 발생하지 않아야 함 + assertThatCode(() -> scenarioService.getScenarioTimeline(scenarioId, userId)) + .doesNotThrowAnyException(); + + verify(objectMapper).readValue(eq(validJson), any(TypeReference.class)); + } + + @Test + @DisplayName("실패 - null 입력값") + void parseTimelineTitles_실패_null_입력값() { + // Given + Long scenarioId = 1001L; + Long userId = 1L; + + Scenario mockScenario = Scenario.builder() + .timelineTitles(null) + .build(); + ReflectionTestUtils.setField(mockScenario, "id", scenarioId); + + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(mockScenario)); + + // When & Then + assertThatThrownBy(() -> scenarioService.getScenarioTimeline(scenarioId, userId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_TIMELINE_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 빈 문자열 입력값") + void parseTimelineTitles_실패_빈_문자열_입력값() { + // Given + Long scenarioId = 1001L; + Long userId = 1L; + + Scenario mockScenario = Scenario.builder() + .timelineTitles(" ") // 공백만 있는 문자열 + .build(); + ReflectionTestUtils.setField(mockScenario, "id", scenarioId); + + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(mockScenario)); + + // When & Then + assertThatThrownBy(() -> scenarioService.getScenarioTimeline(scenarioId, userId)) + .isInstanceOf(ApiException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_TIMELINE_NOT_FOUND); + } + } + + @Nested + @DisplayName("상태 메시지 로직") + class GetStatusMessageTests { + + @Test + @DisplayName("성공 - 모든 상태별 메시지 확인") + void getStatusMessage_성공_모든_상태별_메시지_확인() { + // Given & When & Then - PENDING + Long scenarioId = 1001L; + Long userId = 1L; + + Scenario pendingScenario = Scenario.builder() + .status(ScenarioStatus.PENDING) + .build(); + ReflectionTestUtils.setField(pendingScenario, "id", scenarioId); + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(pendingScenario)); + + ScenarioStatusResponse pendingResult = scenarioService.getScenarioStatus(scenarioId, userId); + assertThat(pendingResult.message()).isEqualTo("시나리오 생성 대기 중입니다."); + + // PROCESSING + Scenario processingScenario = Scenario.builder() + .status(ScenarioStatus.PROCESSING) + .build(); + ReflectionTestUtils.setField(processingScenario, "id", scenarioId); + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(processingScenario)); + + ScenarioStatusResponse processingResult = scenarioService.getScenarioStatus(scenarioId, userId); + assertThat(processingResult.message()).isEqualTo("시나리오를 생성 중입니다."); + + // COMPLETED + Scenario completedScenario = Scenario.builder() + .status(ScenarioStatus.COMPLETED) + .build(); + ReflectionTestUtils.setField(completedScenario, "id", scenarioId); + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(completedScenario)); + + ScenarioStatusResponse completedResult = scenarioService.getScenarioStatus(scenarioId, userId); + assertThat(completedResult.message()).isEqualTo("시나리오 생성이 완료되었습니다."); + + // FAILED + Scenario failedScenario = Scenario.builder() + .status(ScenarioStatus.FAILED) + .build(); + ReflectionTestUtils.setField(failedScenario, "id", scenarioId); + given(scenarioRepository.findByIdAndUserId(scenarioId, userId)) + .willReturn(Optional.of(failedScenario)); + + ScenarioStatusResponse failedResult = scenarioService.getScenarioStatus(scenarioId, userId); + assertThat(failedResult.message()).isEqualTo("시나리오 생성에 실패했습니다. 다시 시도해주세요."); + } + } +} \ No newline at end of file diff --git a/back/src/test/java/com/back/global/config/JsonConfigTest.java b/back/src/test/java/com/back/global/config/JsonConfigTest.java new file mode 100644 index 0000000..f4682c8 --- /dev/null +++ b/back/src/test/java/com/back/global/config/JsonConfigTest.java @@ -0,0 +1,206 @@ +package com.back.global.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +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.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; + +/** + * JsonConfig 설정과 ObjectMapper 동작을 테스트합니다. + * Spring Boot 통합 테스트로 실제 Bean이 정상적으로 동작하는지 검증합니다. + */ +@SpringBootTest +@TestPropertySource(properties = "spring.profiles.active=test") +@DisplayName("JsonConfig 및 ObjectMapper 테스트") +class JsonConfigTest { + + @Autowired + private ObjectMapper objectMapper; + + @Nested + @DisplayName("JSON 파싱 기능") + class JsonParsingTests { + + @Test + @DisplayName("성공 - Map 타입으로 JSON 파싱") + void parseJson_성공_맵타입으로_JSON_파싱() throws JsonProcessingException { + // Given + String json = "{\"2020\":\"창업 시작\",\"2025\":\"상장 성공\",\"2030\":\"글로벌 진출\"}"; + + // When + Map result = objectMapper.readValue(json, new TypeReference>() {}); + + // Then + assertThat(result).isNotNull(); + assertThat(result).hasSize(3); + assertThat(result.get("2020")).isEqualTo("창업 시작"); + assertThat(result.get("2025")).isEqualTo("상장 성공"); + assertThat(result.get("2030")).isEqualTo("글로벌 진출"); + } + + @Test + @DisplayName("성공 - 빈 JSON 객체 파싱") + void parseJson_성공_빈_JSON_객체_파싱() throws JsonProcessingException { + // Given + String emptyJson = "{}"; + + // When + Map result = objectMapper.readValue(emptyJson, new TypeReference>() {}); + + // Then + assertThat(result).isNotNull(); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("성공 - 한글 포함 JSON 파싱") + void parseJson_성공_한글포함_JSON_파싱() throws JsonProcessingException { + // Given + String koreanJson = "{\"직업\":\"개발자\",\"취미\":\"독서\",\"목표\":\"성장하는 개발자\"}"; + + // When + Map result = objectMapper.readValue(koreanJson, new TypeReference>() {}); + + // Then + assertThat(result).isNotNull(); + assertThat(result.get("직업")).isEqualTo("개발자"); + assertThat(result.get("취미")).isEqualTo("독서"); + assertThat(result.get("목표")).isEqualTo("성장하는 개발자"); + } + + @Test + @DisplayName("실패 - 잘못된 JSON 형식") + void parseJson_실패_잘못된_JSON_형식() { + // Given + String invalidJson = "{\"key\":\"value\",}"; // 마지막 콤마로 인한 잘못된 JSON + + // When & Then + assertThatThrownBy(() -> objectMapper.readValue(invalidJson, new TypeReference>() {})) + .isInstanceOf(JsonProcessingException.class); + } + } + + @Nested + @DisplayName("JSON 직렬화 기능") + class JsonSerializationTests { + + @Test + @DisplayName("성공 - Map을 JSON으로 직렬화") + void serializeToJson_성공_맵을_JSON으로_직렬화() throws JsonProcessingException { + // Given + Map data = Map.of( + "2020", "대학 졸업", + "2023", "첫 직장 입사", + "2025", "팀 리더 승진" + ); + + // When + String json = objectMapper.writeValueAsString(data); + + // Then + assertThat(json).isNotNull(); + assertThat(json).contains("\"2020\":\"대학 졸업\""); + assertThat(json).contains("\"2023\":\"첫 직장 입사\""); + assertThat(json).contains("\"2025\":\"팀 리더 승진\""); + } + } + + @Nested + @DisplayName("Java 8 시간 타입 지원") + class JavaTimeModuleTests { + + @Test + @DisplayName("성공 - LocalDateTime 직렬화 및 역직렬화") + void localDateTime_성공_직렬화_역직렬화() throws JsonProcessingException { + // Given + TestTimeObject original = new TestTimeObject(); + original.createdAt = LocalDateTime.of(2024, 9, 17, 14, 30, 0); + + // When - 직렬화 + String json = objectMapper.writeValueAsString(original); + + // Then - JSON이 타임스탬프가 아닌 문자열 형태인지 확인 + assertThat(json).contains("2024-09-17T14:30:00"); + assertThat(json).doesNotContain("1726574200"); // 타임스탬프 형식이 아님 + + // When - 역직렬화 + TestTimeObject deserialized = objectMapper.readValue(json, TestTimeObject.class); + + // Then + assertThat(deserialized.createdAt).isEqualTo(original.createdAt); + } + + private static class TestTimeObject { + public LocalDateTime createdAt; + } + } + + @Nested + @DisplayName("알려지지 않은 속성 처리") + class UnknownPropertiesTests { + + @Test + @DisplayName("성공 - 알려지지 않은 속성이 있어도 파싱 성공") + void unknownProperties_성공_알려지지않은_속성_무시() throws JsonProcessingException { + // Given + String jsonWithUnknownProperty = """ + { + "name": "홍길동", + "age": 25, + "unknownField": "무시될 필드", + "anotherUnknown": 12345 + } + """; + + // When & Then - 예외가 발생하지 않아야 함 + assertThatCode(() -> { + TestSimpleObject result = objectMapper.readValue(jsonWithUnknownProperty, TestSimpleObject.class); + assertThat(result.name).isEqualTo("홍길동"); + assertThat(result.age).isEqualTo(25); + }).doesNotThrowAnyException(); + } + + private static class TestSimpleObject { + public String name; + public int age; + } + } + + @Nested + @DisplayName("ObjectMapper Bean 설정 검증") + class BeanConfigurationTests { + + @Test + @DisplayName("성공 - ObjectMapper Bean이 정상적으로 주입됨") + void objectMapperBean_성공_정상적으로_주입됨() { + // Given & When & Then + assertThat(objectMapper).isNotNull(); + assertThat(objectMapper).isInstanceOf(ObjectMapper.class); + } + + @Test + @DisplayName("성공 - JsonConfig 설정이 적용됨") + void jsonConfig_성공_설정이_적용됨() { + // Given & When & Then + // FAIL_ON_UNKNOWN_PROPERTIES가 false로 설정되었는지 확인 + assertThat(objectMapper.getDeserializationConfig().isEnabled( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) + .isFalse(); + + // WRITE_DATES_AS_TIMESTAMPS가 비활성화되었는지 확인 + assertThat(objectMapper.getSerializationConfig().isEnabled( + com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)) + .isFalse(); + } + } +} \ No newline at end of file