diff --git a/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java b/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java index be5955a..f98162f 100644 --- a/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java +++ b/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java @@ -1,12 +1,13 @@ /** - * [DTO-RES] DecisionNode 응답(보강) - * - followPolicy/pinnedCommitId/virtual 플래그를 추가해 해석 상태를 노출 - * - effective* 필드는 Resolver가 계산한 최종 표현값(렌더 우선 사용) + * [DTO-RES] DecisionNode 응답(보강 최종본) + * - 정책/핀/가상(followPolicy/pinnedCommitId/virtual)과 effective* 해석값 노출 + * - 렌더 편의: childrenIds/root/pivot 링크 + 단일 패스 렌더용 힌트(renderPhase, incoming*) */ package com.back.domain.node.dto.decision; import com.back.domain.node.entity.FollowPolicy; import com.back.domain.node.entity.NodeCategory; + import java.util.List; public record DecNodeDto( @@ -40,10 +41,16 @@ public record DecNodeDto( List effectiveOptions, String effectiveDescription, + // 기본 렌더 편의 List childrenIds, // 이 DECISION의 자식들 id(시간순) Boolean root, // 라인 헤더면 true Long pivotLinkBaseNodeId, // 베이스 분기 슬롯에서 올라온 첫 노드면 해당 BaseNode id - Integer pivotSlotIndex // 0/1 (분기 슬롯 인덱스), 아니면 null + Integer pivotSlotIndex, // 0/1 (분기 슬롯 인덱스), 아니면 null + + // 단일 패스 렌더용 최소 힌트 + Integer renderPhase, // 1=from-base 라인, 2..N=fork 깊이(부모 라인 +1) + Long incomingFromId, // 이 노드로 "들어오는" 에지의 from 노드 id(루트면 포크 원본, 아니면 parentId) + String incomingEdgeType // "normal" | "fork" ) { // === 호환 오버로드(기존 서비스 호출 유지) === public DecNodeDto( @@ -62,7 +69,7 @@ public DecNodeDto( parentOptionIndex, description, aiNextSituation, aiNextRecommendedOption, followPolicy, pinnedCommitId, virtual, effectiveCategory, effectiveSituation, effectiveDecision, effectiveOptions, effectiveDescription, - null, null, null, null); // ← 새 필드는 null 기본값 + null, null, null, null, + null, null, null); // 새 필드는 null 기본값 } - } diff --git a/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java index 7b88a6f..1b4a1ca 100644 --- a/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java +++ b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java @@ -311,7 +311,6 @@ private Long currentUserId() { public DecisionLineLifecycleDto completeDecisionLine(Long decisionLineId) { DecisionLine line = support.requireDecisionLine(decisionLineId); try { line.complete(); } catch (RuntimeException e) { throw support.mapDomainToApi(e); } - decisionLineRepository.save(line); return new DecisionLineLifecycleDto(line.getId(), line.getStatus()); } diff --git a/back/src/main/java/com/back/domain/node/service/NodeQueryService.java b/back/src/main/java/com/back/domain/node/service/NodeQueryService.java index ce03ba1..6d7dc4b 100644 --- a/back/src/main/java/com/back/domain/node/service/NodeQueryService.java +++ b/back/src/main/java/com/back/domain/node/service/NodeQueryService.java @@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -52,13 +53,22 @@ public TreeDto getTreeForBaseLine(Long baseLineId) { List orderedBase = support.getOrderedBaseNodes(baseLineId); List baseDtos = orderedBase.stream().map(mappers.BASE_READ::map).toList(); - // 전체 라인 공용 pivot 역인덱스 + // 가장 많이 사용하는: 전체 라인 공용 pivot 역인덱스 Map pivotIndex = buildPivotIndex(baseLineId); + // ===== (1) 노드 수집 ===== + record Key(Long baseId, Integer age) {} + record View(DecisionNode dn, DecNodeDto dto, boolean isRoot, Long baseId, Integer age, + List childrenIds, Long pivotBaseId, Integer pivotSlot) {} + List decDtos = new ArrayList<>(); List lines = decisionLineRepository.findByBaseLine_Id(baseLineId); + List pool = new ArrayList<>(); + Map> byLine = new HashMap<>(); + for (DecisionLine line : lines) { + // 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 라인의 노드를 타임라인 정렬로 로드 List ordered = decisionNodeRepository .findByDecisionLine_IdOrderByAgeYearAscIdAsc(line.getId()); @@ -67,29 +77,126 @@ public TreeDto getTreeForBaseLine(Long baseLineId) { for (DecisionNode dn : ordered) { DecNodeDto base = mappers.DECISION_READ.map(dn); - List childrenIds = childrenIndex.getOrDefault(dn.getId(), List.of()); boolean isRoot = (dn.getParent() == null); + List childrenIds = childrenIndex.getOrDefault(dn.getId(), List.of()); PivotMark mark = pivotIndex.get(base.id()); - Long pivotBaseId = (mark != null) ? mark.baseNodeId() : null; + Long pivotBaseId = (mark != null) ? mark.baseNodeId() : + (dn.getBaseNode() != null ? dn.getBaseNode().getId() : null); Integer pivotSlot = (mark != null) ? mark.slotIndex() : null; - // effective*는 DECISION_READ에서 이미 계산/주입되어 있다면 그대로 사용 - decDtos.add(new DecNodeDto( - base.id(), base.userId(), base.type(), base.category(), - base.situation(), base.decision(), base.ageYear(), - base.decisionLineId(), base.parentId(), base.baseNodeId(), - base.background(), base.options(), base.selectedIndex(), - base.parentOptionIndex(), base.description(), - base.aiNextSituation(), base.aiNextRecommendedOption(), - base.followPolicy(), base.pinnedCommitId(), base.virtual(), - base.effectiveCategory(), base.effectiveSituation(), base.effectiveDecision(), - base.effectiveOptions(), base.effectiveDescription(), - // ▼ 렌더 편의 필드 - List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot - )); + Long baseId = pivotBaseId; + Integer age = dn.getAgeYear(); + + View v = new View(dn, base, isRoot, baseId, age, List.copyOf(childrenIds), pivotBaseId, pivotSlot); + pool.add(v); + byLine.computeIfAbsent(line.getId(), k -> new ArrayList<>()).add(v); } } + + Map> byKey = pool.stream() + .filter(v -> v.baseId != null && v.age != null) + .collect(Collectors.groupingBy(v -> new Key(v.baseId, v.age))); + + // ===== (2) 라인 간 포크 그래프 추론 → renderPhase 계산 ===== + Map rootCand = new HashMap<>(); // lineId -> 루트 후보 + for (Map.Entry> e : byLine.entrySet()) { + List vs = e.getValue().stream() + .sorted(Comparator.comparing((View x) -> x.age) + .thenComparing(x -> x.dn.getId())) + .toList(); + + View first = vs.get(0); + // 가장 중요한 함수: 라인 루트가 헤더면 다음 피벗을 루트 후보로 대체 + View cand = (first.isRoot && first.baseId == null && vs.size() > 1) ? vs.get(1) : first; + rootCand.put(e.getKey(), cand); + } + + Map> g = new HashMap<>(); // originLineId -> {forkLineId} + Map indeg = new HashMap<>(); // lineId -> indegree + + for (Map.Entry e : rootCand.entrySet()) { + Long lineId = e.getKey(); + View me = e.getValue(); + indeg.putIfAbsent(lineId, 0); + + if (me.baseId != null && me.age != null) { + List same = byKey.getOrDefault(new Key(me.baseId, me.age), List.of()); + Optional origin = same.stream() + .filter(o -> !o.dn.getDecisionLine().getId().equals(lineId)) + .sorted(Comparator + .comparing((View o) -> o.dn.getDecisionLine().getId()) + .thenComparing(o -> o.dn.getId())) + .findFirst(); + + if (origin.isPresent()) { + Long originLineId = origin.get().dn.getDecisionLine().getId(); + g.computeIfAbsent(originLineId, k -> new HashSet<>()).add(lineId); + indeg.put(lineId, indeg.getOrDefault(lineId, 0) + 1); + indeg.putIfAbsent(originLineId, 0); + } + } + } + + Map linePhase = new HashMap<>(); // lineId -> phase + ArrayDeque q = new ArrayDeque<>(); + for (Map.Entry e : indeg.entrySet()) { + if (e.getValue() == 0) { // indegree==0 → from-base + linePhase.put(e.getKey(), 1); + q.add(e.getKey()); + } + } + while (!q.isEmpty()) { + Long u = q.poll(); + int next = linePhase.get(u) + 1; + for (Long v : g.getOrDefault(u, Set.of())) { + indeg.put(v, indeg.get(v) - 1); + linePhase.put(v, Math.max(linePhase.getOrDefault(v, 1), next)); + if (indeg.get(v) == 0) q.add(v); + } + } + + // ===== (3) DTO 주입: renderPhase + incomingFromId/incomingEdgeType ===== + for (View v : pool) { + DecNodeDto b = v.dto; + + Long lineId = v.dn.getDecisionLine().getId(); + Integer renderPhase = linePhase.getOrDefault(lineId, 1); + + Long incomingFromId = (v.isRoot) + ? byKey.getOrDefault(new Key(v.baseId, v.age), List.of()).stream() + .filter(o -> !o.dn.getDecisionLine().getId().equals(lineId)) + .sorted(Comparator + .comparing((View o) -> o.dn.getDecisionLine().getId()) + .thenComparing(o -> o.dn.getId())) + .map(o -> o.dn.getId()) + .findFirst() + .orElse(null) + : (v.dn.getParent() != null ? v.dn.getParent().getId() : null); + + String incomingEdgeType = (v.isRoot && incomingFromId != null) ? "fork" : "normal"; + + decDtos.add(new DecNodeDto( + b.id(), b.userId(), b.type(), b.category(), + b.situation(), b.decision(), b.ageYear(), + b.decisionLineId(), b.parentId(), b.baseNodeId(), + b.background(), b.options(), b.selectedIndex(), + b.parentOptionIndex(), b.description(), + b.aiNextSituation(), b.aiNextRecommendedOption(), + b.followPolicy(), b.pinnedCommitId(), b.virtual(), + b.effectiveCategory(), b.effectiveSituation(), b.effectiveDecision(), + b.effectiveOptions(), b.effectiveDescription(), + v.childrenIds, v.isRoot, v.pivotBaseId, v.pivotSlot, + renderPhase, incomingFromId, incomingEdgeType + )); + } + + // 출력 순서 보장: phase → ageYear → id + decDtos.sort(Comparator + .comparing(DecNodeDto::renderPhase, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(DecNodeDto::ageYear, Comparator.nullsFirst(Integer::compareTo)) + .thenComparing(DecNodeDto::id)); + return new TreeDto(baseDtos, decDtos); } @@ -142,7 +249,8 @@ public DecisionLineListDto getDecisionLines(Long userId) { // 가장 중요한: 특정 라인의 상세를 childrenIds/root/pivotLink*와 함께 반환 public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { DecisionLine line = decisionLineRepository.findById(decisionLineId) - .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND, "DecisionLine not found: " + decisionLineId)); + .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND, + "DecisionLine not found: " + decisionLineId)); Long baseLineId = line.getBaseLine().getId(); Long baseBranchId = (line.getBaseBranch() != null) ? line.getBaseBranch().getId() : null; @@ -153,7 +261,7 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { // parent→children 인덱스 구성 Map> childrenIndex = buildChildrenIndex(ordered); - // 베이스 분기 슬롯 역인덱스 구성(altOpt1/2TargetDecisionId → (baseNodeId, slot)) + // 베이스 분기 슬롯 역인덱스 구성 Map pivotIndex = buildPivotIndex(baseLineId); List nodes = ordered.stream().map(n -> { @@ -178,12 +286,12 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { if (verId != null) { NodeAtomVersion v = versionRepo.findById(verId).orElse(null); if (v != null) { - if (v.getCategory() != null) effCategory = v.getCategory(); - if (v.getSituation() != null) effSituation = v.getSituation(); - if (v.getDecision() != null) effDecision = v.getDecision(); + if (v.getCategory() != null) effCategory = v.getCategory(); + if (v.getSituation() != null) effSituation = v.getSituation(); + if (v.getDecision() != null) effDecision = v.getDecision(); List parsed = parseOptionsJson(v.getOptionsJson()); - if (parsed != null) effOpts = parsed; - if (v.getDescription() != null) effDesc = v.getDescription(); + if (parsed != null) effOpts = parsed; + if (v.getDescription() != null) effDesc = v.getDescription(); } } @@ -194,6 +302,12 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { Long pivotBaseId = (mark != null) ? mark.baseNodeId() : null; Integer pivotSlot = (mark != null) ? mark.slotIndex() : null; + // ===== 라인 상세 전용 렌더 힌트 ===== + // 한줄 요약: 상세 화면은 한 라인만 보므로 phase=1 고정, incoming은 parent 기준(normal) + Integer renderPhase = 1; + Long incomingFromId = isRoot ? null : n.getParent().getId(); + String incomingEdgeType = "normal"; + return new DecNodeDto( base.id(), base.userId(), base.type(), base.category(), base.situation(), base.decision(), base.ageYear(), @@ -202,10 +316,11 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { base.parentOptionIndex(), base.description(), base.aiNextSituation(), base.aiNextRecommendedOption(), base.followPolicy(), base.pinnedCommitId(), base.virtual(), - // effective* + // effective* (최종 해석 반영) effCategory, effSituation, effDecision, effOpts, effDesc, - // ▼ 렌더 편의 필드 - List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot + // ▼ 렌더 편의 + 단일 패스 힌트(라인 내부 한정) + List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot, + renderPhase, incomingFromId, incomingEdgeType ); }).toList(); 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 6c94e0c..13646bf 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 @@ -1,5 +1,6 @@ package com.back.domain.scenario.controller; +import com.back.domain.node.dto.decision.DecisionNodeNextRequest; import com.back.domain.scenario.dto.*; import com.back.domain.scenario.service.ScenarioService; import com.back.global.common.PageResponse; @@ -42,12 +43,13 @@ private Long getUserId(CustomUserDetails userDetails) { @PostMapping @Operation(summary = "시나리오 생성", description = "DecisionLine을 기반으로 AI 시나리오를 생성합니다.") public ResponseEntity createScenario( - @Valid @RequestBody ScenarioCreateRequest request, + @Valid @RequestPart("scenario") ScenarioCreateRequest request, + @RequestPart(value = "lastDecision", required = false) DecisionNodeNextRequest lastDecision, @AuthenticationPrincipal CustomUserDetails userDetails ) { Long userId = getUserId(userDetails); - ScenarioStatusResponse scenarioCreateResponse = scenarioService.createScenario(userId, request); + ScenarioStatusResponse scenarioCreateResponse = scenarioService.createScenario(userId,request, lastDecision); return ResponseEntity.status(HttpStatus.CREATED).body(scenarioCreateResponse); } diff --git a/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java b/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java index bce06de..e46ed03 100644 --- a/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java +++ b/back/src/main/java/com/back/domain/scenario/service/ScenarioService.java @@ -1,10 +1,11 @@ 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.entity.NodeCategory; +import com.back.domain.node.dto.decision.DecisionNodeNextRequest; +import com.back.domain.node.entity.*; import com.back.domain.node.repository.BaseLineRepository; import com.back.domain.node.repository.DecisionLineRepository; +import com.back.domain.node.repository.DecisionNodeRepository; +import com.back.domain.node.service.DecisionFlowService; import com.back.domain.scenario.dto.*; import com.back.domain.scenario.entity.Scenario; import com.back.domain.scenario.entity.ScenarioStatus; @@ -20,6 +21,7 @@ import com.back.global.exception.ErrorCode; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nullable; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -52,6 +54,7 @@ public class ScenarioService { // Node Repository 주입 private final DecisionLineRepository decisionLineRepository; private final BaseLineRepository baseLineRepository; + private final DecisionNodeRepository decisionNodeRepository; // Object Mapper 주입 private final ObjectMapper objectMapper; @@ -62,9 +65,14 @@ public class ScenarioService { // Scenario Transaction Service 주입 private final ScenarioTransactionService scenarioTransactionService; + // 노드 서비스 추가(시나리오 생성과 동시에 마지막 노드 처리용) + private final DecisionFlowService decisionFlowService; + // 시나리오 생성 @Transactional - public ScenarioStatusResponse createScenario(Long userId, ScenarioCreateRequest request) { + public ScenarioStatusResponse createScenario(Long userId, + ScenarioCreateRequest request, + @Nullable DecisionNodeNextRequest lastDecision) { // DecisionLine 존재 여부 확인 DecisionLine decisionLine = decisionLineRepository.findById(request.decisionLineId()) .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND)); @@ -103,6 +111,18 @@ public ScenarioStatusResponse createScenario(Long userId, ScenarioCreateRequest } } + ensureOwnerEditable(userId, decisionLine); + + if (lastDecision != null) { + ensureSameLine(decisionLine, lastDecision); + decisionFlowService.createDecisionNodeNext(lastDecision); + } + + // 라인 완료 처리(외부 완료 API 제거 시 내부에서만 호출) + try { decisionLine.complete(); } catch (RuntimeException e) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, e.getMessage()); + } + // 새 시나리오 생성 (DataIntegrityViolationException 처리) try { // DecisionLine에서 BaseLine 가져오기 @@ -136,6 +156,62 @@ public ScenarioStatusResponse createScenario(Long userId, ScenarioCreateRequest } } + // 가장 많이 사용하는 함수 호출 위 한줄 요약: 시나리오 요청에서 lineId 필수 추출 + private Long requireLineId(ScenarioCreateRequest scenario) { + if (scenario == null || scenario.decisionLineId() == null) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "scenario.decisionLineId is required"); + } + return scenario.decisionLineId(); + } + + // 한줄 요약: 소유자/편집 가능 상태 검증 + private void ensureOwnerEditable(Long userId, DecisionLine line) { + if (!line.getUser().getId().equals(userId)) + throw new ApiException(ErrorCode.HANDLE_ACCESS_DENIED, "not line owner"); + if (line.getStatus() == DecisionLineStatus.COMPLETED || line.getStatus() == DecisionLineStatus.CANCELLED) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "line is not editable"); + } + + // lastDecision의 부모 노드가 같은 라인/같은 사용자/유효한 시퀀스인지 검증 + private void ensureSameLine(DecisionLine line, DecisionNodeNextRequest lastDecision) { + if (lastDecision.parentDecisionNodeId() == null) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "lastDecision.parentDecisionNodeId is required"); + } + + // 부모 노드 id로 조회(없으면 404) + DecisionNode parent = decisionNodeRepository.findById(lastDecision.parentDecisionNodeId()) + .orElseThrow(() -> new ApiException( + ErrorCode.NODE_NOT_FOUND, + "parent decision node not found: " + lastDecision.parentDecisionNodeId() + )); + + // 같은 라인인지 강제 + if (!parent.getDecisionLine().getId().equals(line.getId())) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "parent node does not belong to the target line"); + } + + // 소유자 일치 강제(추가 방어) + Long ownerIdOfLine = line.getUser().getId(); + if (!parent.getUser().getId().equals(ownerIdOfLine)) { + throw new ApiException(ErrorCode.HANDLE_ACCESS_DENIED, "parent node is not owned by line owner"); + } + if (!parent.getDecisionLine().getUser().getId().equals(ownerIdOfLine)) { + throw new ApiException(ErrorCode.HANDLE_ACCESS_DENIED, "line owner mismatch on parent node"); + } + + // 시퀀스 유효성: ageYear가 지정됐다면 반드시 부모 이후여야 함 + Integer nextAge = lastDecision.ageYear(); + if (nextAge != null && nextAge <= parent.getAgeYear()) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "ageYear must be greater than parent.ageYear"); + } + + // 편집 가능 상태 재확인(이미 상위에서 검증했더라도 이중 방어) + if (line.getStatus() == DecisionLineStatus.COMPLETED || line.getStatus() == DecisionLineStatus.CANCELLED) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "line is not editable"); + } + } + + // FAILED 시나리오 재시도 로직 분리 private ScenarioStatusResponse handleFailedScenarioRetry(Scenario failedScenario) { failedScenario.setStatus(ScenarioStatus.PENDING); 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 index 92a7104..0c2a8f7 100644 --- a/back/src/test/java/com/back/domain/scenario/controller/ScenarioControllerTest.java +++ b/back/src/test/java/com/back/domain/scenario/controller/ScenarioControllerTest.java @@ -1,9 +1,12 @@ package com.back.domain.scenario.controller; +import com.back.domain.node.dto.decision.DecisionNodeNextRequest; +import com.back.domain.node.entity.NodeCategory; 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.common.PageResponse; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,11 +18,10 @@ 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 com.back.global.common.PageResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; -import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -31,7 +33,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -75,13 +77,41 @@ class CreateScenario { "시나리오 생성이 시작되었습니다." ); - given(scenarioService.createScenario(eq(1L), any(ScenarioCreateRequest.class))) + DecisionNodeNextRequest lastNode= new DecisionNodeNextRequest( + 1L, + 100L, + NodeCategory.CAREER, + "커리어 방향 선택", + 28, + List.of("이직", "석사과정", "창업"), + 2, + 1, + "리스크 감수" + ); + + given(scenarioService.createScenario(eq(1L), any(ScenarioCreateRequest.class), + any(DecisionNodeNextRequest.class))) .willReturn(mockResponse); + // request를 JSON으로 직렬화 (컨트롤러 @RequestPart("scenario")가 이걸 받음) + byte[] scenarioJson = objectMapper.writeValueAsBytes(request); + + MockMultipartFile scenarioPart = new MockMultipartFile( + "scenario", + "", "application/json", + scenarioJson + ); + MockMultipartFile lastDecisionPart = new MockMultipartFile( + "lastDecision", + "", "application/json", + objectMapper.writeValueAsBytes(lastNode) + ); + // When & Then - mockMvc.perform(post("/api/v1/scenarios") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform(multipart("/api/v1/scenarios") + .file(scenarioPart) + .file(lastDecisionPart) + .with(req -> { req.setMethod("POST"); return req; })) .andDo(print()) .andExpect(status().isCreated()) .andExpect(jsonPath("$.scenarioId").value(1001)) @@ -90,32 +120,63 @@ class CreateScenario { } @Test - @DisplayName("실패 - 잘못된 요청 데이터 (null decisionLineId)") - void createScenario_실패_잘못된요청() throws Exception { - // Given - String invalidRequest = "{\"decisionLineId\":null}"; + @DisplayName("실패 - 잘못된 요청 데이터 (null decisionLineId) - multipart") + void createScenario_실패_잘못된요청_multipart() throws Exception { + // Given: decisionLineId=null인 잘못된 JSON을 그대로 scenario 파트에 실어 보냄 + byte[] invalidScenarioJson = "{\"decisionLineId\":null}".getBytes(); + + MockMultipartFile scenarioPart = new MockMultipartFile( + "scenario", "", "application/json", invalidScenarioJson + ); // When & Then - mockMvc.perform(post("/api/v1/scenarios") - .contentType(MediaType.APPLICATION_JSON) - .content(invalidRequest)) + mockMvc.perform(multipart("/api/v1/scenarios") + .file(scenarioPart) + .with(req -> { req.setMethod("POST"); return req; })) .andDo(print()) .andExpect(status().isBadRequest()); } @Test - @DisplayName("실패 - Service 예외 발생") - void createScenario_실패_Service예외() throws Exception { + @DisplayName("실패 - Service 예외 발생 - multipart") + void createScenario_실패_Service예외_multipart() throws Exception { // Given ScenarioCreateRequest request = new ScenarioCreateRequest(999L); - given(scenarioService.createScenario(eq(1L), any(ScenarioCreateRequest.class))) - .willThrow(new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND)); + DecisionNodeNextRequest lastNode = new DecisionNodeNextRequest( + 1L, + 456L, + NodeCategory.CAREER, + "커리어 방향 선택", + 28, + List.of("이직", "석사과정", "창업"), + 2, + 1, + "리스크 감수" + ); + + // 서비스 예외 스텁 + given(scenarioService.createScenario( + eq(1L), + any(ScenarioCreateRequest.class), + any(DecisionNodeNextRequest.class) + )).willThrow(new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND)); + + // 멀티파트 파트 구성 + MockMultipartFile scenarioPart = new MockMultipartFile( + "scenario", "", "application/json", + objectMapper.writeValueAsBytes(request) + ); + MockMultipartFile lastDecisionPart = new MockMultipartFile( + "lastDecision", "", "application/json", + objectMapper.writeValueAsBytes(lastNode) + ); // When & Then - mockMvc.perform(post("/api/v1/scenarios") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform(multipart("/api/v1/scenarios") + .file(scenarioPart) + .file(lastDecisionPart) + .with(req -> { req.setMethod("POST"); return req; })) .andDo(print()) .andExpect(status().isNotFound()); } 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 index 6f2bc7b..a605cb3 100644 --- a/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java +++ b/back/src/test/java/com/back/domain/scenario/service/ScenarioServiceTest.java @@ -92,7 +92,7 @@ class CreateScenarioTests { doNothing().when(scenarioService).processScenarioGenerationAsync(anyLong()); // When - ScenarioStatusResponse result = scenarioService.createScenario(userId, request); + ScenarioStatusResponse result = scenarioService.createScenario(userId, request, null); // Then - 시나리오 생성 요청이 접수되고 PENDING 상태로 반환되는지만 검증 assertThat(result).isNotNull(); @@ -121,7 +121,7 @@ class CreateScenarioTests { .willReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> scenarioService.createScenario(userId, request)) + assertThatThrownBy(() -> scenarioService.createScenario(userId, request, null)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DECISION_LINE_NOT_FOUND); @@ -150,7 +150,7 @@ class CreateScenarioTests { .willReturn(Optional.of(mockDecisionLine)); // When & Then - assertThatThrownBy(() -> scenarioService.createScenario(userId, request)) + assertThatThrownBy(() -> scenarioService.createScenario(userId, request, null)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.HANDLE_ACCESS_DENIED); @@ -188,7 +188,7 @@ class CreateScenarioTests { .willReturn(Optional.of(existingScenario)); // 기존 PENDING 시나리오 존재 // When & Then - assertThatThrownBy(() -> scenarioService.createScenario(userId, request)) + assertThatThrownBy(() -> scenarioService.createScenario(userId, request, null)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SCENARIO_ALREADY_IN_PROGRESS);