diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/controller/JobRoadmapController.java b/back/src/main/java/com/back/domain/roadmap/roadmap/controller/JobRoadmapController.java new file mode 100644 index 00000000..64433b52 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/controller/JobRoadmapController.java @@ -0,0 +1,46 @@ +package com.back.domain.roadmap.roadmap.controller; + +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapListResponse; +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapPagingResponse; +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapResponse; +import com.back.domain.roadmap.roadmap.service.JobRoadmapService; +import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/job-roadmaps") +@RequiredArgsConstructor +@Tag(name = "JobRoadmap Controller", description = "직업별 통합 로드맵 관련 API") +public class JobRoadmapController { + private final JobRoadmapService jobRoadmapService; + + @GetMapping + @Operation( + summary = "직업 로드맵 다건 조회", + description = "직업 로드맵 목록을 페이징과 키워드 검색으로 조회합니다." + ) + public RsData getJobRoadmaps( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String keyword + ) { + Page jobRoadmapPage = jobRoadmapService.getJobRoadmaps(keyword, page, size); + JobRoadmapPagingResponse response = JobRoadmapPagingResponse.from(jobRoadmapPage); + + return new RsData<>("200", "직업 로드맵 목록 조회 성공", response); + } + + @GetMapping("/{id}") + @Operation( + summary = "직업 로드맵 상세 조회", + description = "특정 직업 로드맵의 상세 정보(직업 정보 + 모든 노드)를 조회합니다." + ) + public RsData getJobRoadmapById(@PathVariable Long id) { + JobRoadmapResponse roadmap = jobRoadmapService.getJobRoadmapById(id); + return new RsData<>("200", "직업 로드맵 상세 조회 성공", roadmap); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapListResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapListResponse.java new file mode 100644 index 00000000..5cafedee --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapListResponse.java @@ -0,0 +1,26 @@ +package com.back.domain.roadmap.roadmap.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "직업 로드맵 다건 조회 응답") +public record JobRoadmapListResponse( + @Schema(description = "로드맵 ID") + Long id, + + @Schema(description = "직업명") + String jobName, + + @Schema(description = "직업 설명 (150자 제한)") + String jobDescription +) { + // 정적 팩토리 메서드 + public static JobRoadmapListResponse of(Long id, String jobName, String jobDescription) { + return new JobRoadmapListResponse(id, jobName, truncateDescription(jobDescription)); + } + + // description 자르기 로직 (150자 초과 시 "..." 추가) + private static String truncateDescription(String description) { + if (description == null) return null; + return description.length() > 150 ? description.substring(0, 150) + "..." : description; + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java new file mode 100644 index 00000000..1e925d82 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapNodeResponse.java @@ -0,0 +1,52 @@ +package com.back.domain.roadmap.roadmap.dto.response; + +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; + +public record JobRoadmapNodeResponse( + Long id, + Long parentId, // 부모 노드 ID (null이면 루트 노드) + List childIds, // 자식 노드 ID 목록 (프론트엔드 렌더링용) + Long taskId, // Task와 연결된 경우의 표준 Task ID + String taskName, // 표시용 Task 이름 + String description, + int stepOrder, + int level, // 트리 깊이 (0: 루트, 1: 1단계 자식...) + boolean isLinkedToTask, + Double weight, // 이 노드의 가중치 (JobRoadmapNodeStat에서) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + List children +) { + + // 정적 팩토리 메서드 - RoadmapNode로부터 Response DTO 생성 (자식 노드 정보 포함) + public static JobRoadmapNodeResponse from(RoadmapNode node, List children) { + List childIds = children != null ? + children.stream().map(JobRoadmapNodeResponse::id).toList() : + List.of(); + + return new JobRoadmapNodeResponse( + node.getId(), + node.getParent() != null ? node.getParent().getId() : null, + childIds, + node.getTask() != null ? node.getTask().getId() : null, + node.getTask() != null ? node.getTask().getName() : node.getTaskName(), + node.getDescription(), + node.getStepOrder(), + node.getLevel(), + node.getTask() != null, + null, // weight는 서비스에서 별도로 설정 + children != null ? children : List.of() + ); + } + + // 가중치 설정 헬퍼 메서드 (불변 객체이므로 새 인스턴스 반환) + public JobRoadmapNodeResponse withWeight(Double weight) { + return new JobRoadmapNodeResponse( + this.id, this.parentId, this.childIds, this.taskId, this.taskName, this.description, + this.stepOrder, this.level, this.isLinkedToTask, weight, this.children + ); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapPagingResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapPagingResponse.java new file mode 100644 index 00000000..1f3edf3d --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapPagingResponse.java @@ -0,0 +1,34 @@ +package com.back.domain.roadmap.roadmap.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Schema(description = "직업 로드맵 페이징 조회 응답") +public record JobRoadmapPagingResponse( + @Schema(description = "직업 로드맵 목록") + List jobRoadmaps, + + @Schema(description = "현재 페이지 (0부터 시작)") + int currentPage, + + @Schema(description = "총 페이지") + int totalPage, + + @Schema(description = "총 개수") + long totalElements, + + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext +) { + public static JobRoadmapPagingResponse from(Page page) { + return new JobRoadmapPagingResponse( + page.getContent(), + page.getNumber(), + page.getTotalPages(), + page.getTotalElements(), + page.hasNext() + ); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java new file mode 100644 index 00000000..d33a5849 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/dto/response/JobRoadmapResponse.java @@ -0,0 +1,82 @@ +package com.back.domain.roadmap.roadmap.dto.response; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record JobRoadmapResponse( + Long id, + Long jobId, + String jobName, + List nodes, + int totalNodeCount, + LocalDateTime createdDate, + LocalDateTime modifiedDate +) { + + // 정적 팩터리 메서드 - JobRoadmap과 Job 정보로부터 Response DTO 생성 + public static JobRoadmapResponse from(JobRoadmap jobRoadmap, String jobName) { + // 부모-자식 관계 맵 생성 + Map> childrenMap = jobRoadmap.getNodes().stream() + .filter(node -> node.getParent() != null) + .collect(Collectors.groupingBy(node -> node.getParent().getId())); + + // 노드를 재귀적으로 변환하는 함수 + Map nodeResponseMap = new HashMap<>(); + + // 모든 노드를 bottom-up 방식으로 변환 (자식부터 부모 순서) + buildNodeResponses(jobRoadmap.getNodes(), childrenMap, nodeResponseMap); + + // 루트 노드들만 반환 (자식 노드들은 children 필드에 포함되어 전체 트리 구조 제공) + List nodes = jobRoadmap.getNodes().stream() + .filter(node -> node.getParent() == null) + .map(node -> nodeResponseMap.get(node.getId())) + .sorted((a, b) -> { + int levelCompare = Integer.compare(a.level(), b.level()); + return levelCompare != 0 ? levelCompare : Integer.compare(a.stepOrder(), b.stepOrder()); + }) + .toList(); + + return new JobRoadmapResponse( + jobRoadmap.getId(), + jobRoadmap.getJob().getId(), + jobName, + nodes, + jobRoadmap.getNodes().size(), + jobRoadmap.getCreateDate(), + jobRoadmap.getModifyDate() + ); + } + + // 노드 응답 객체들을 재귀적으로 구성하는 헬퍼 메서드 + private static void buildNodeResponses( + List allNodes, + Map> childrenMap, + Map nodeResponseMap) { + + // 노드들을 level 역순으로 정렬 (깊은 노드부터 처리) + List sortedNodes = allNodes.stream() + .sorted((a, b) -> Integer.compare(b.getLevel(), a.getLevel())) + .toList(); + + for (RoadmapNode node : sortedNodes) { + // 자식 노드들의 응답 객체 가져오기 + List childResponses = childrenMap + .getOrDefault(node.getId(), List.of()) + .stream() + .map(child -> nodeResponseMap.get(child.getId())) + .filter(response -> response != null) + .sorted((a, b) -> Integer.compare(a.stepOrder(), b.stepOrder())) + .toList(); + + // 현재 노드의 응답 객체 생성 + JobRoadmapNodeResponse nodeResponse = JobRoadmapNodeResponse.from(node, childResponses); + nodeResponseMap.put(node.getId(), nodeResponse); + } + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmap.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmap.java index 50d678e9..d62a74a7 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmap.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmap.java @@ -1,10 +1,11 @@ package com.back.domain.roadmap.roadmap.entity; +import com.back.domain.job.job.entity.Job; import com.back.global.jpa.BaseEntity; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import org.hibernate.annotations.SQLRestriction; import java.util.ArrayList; @@ -12,21 +13,23 @@ @Entity @Table(name = "job_roadmap") -@Getter @Setter +@Getter @NoArgsConstructor public class JobRoadmap extends BaseEntity { - @Column(name = "job_id", nullable = false) - private Long jobId; // Job FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "job_id", nullable = false) + private Job job; - @OneToMany(fetch = FetchType.LAZY) - @JoinColumn(name = "roadmap_id") // RoadmapNode.roadmapId 참조 + @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "roadmap_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) @SQLRestriction("roadmap_type = 'JOB'") - @OrderBy("stepOrder ASC") + @OrderBy("level ASC, stepOrder ASC") private List nodes; - public JobRoadmap(Long jobId) { - this.jobId = jobId; - this.nodes = new ArrayList<>(); + @Builder + public JobRoadmap(Job job, List nodes) { + this.job = job; + this.nodes = nodes != null ? nodes : new ArrayList<>(); } public RoadmapNode getRootNode() { diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java index bc75b026..cbdbd117 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java @@ -2,22 +2,45 @@ import com.back.global.jpa.BaseEntity; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; @Entity @Table(name = "job_roadmap_node_stat") -@Getter @Setter +@Getter @NoArgsConstructor public class JobRoadmapNodeStat extends BaseEntity { @Column(name = "step_order") private Integer stepOrder; @Column(name = "weight", nullable = false) - private Double weight = 0.0; + private Double weight; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "node_id", nullable = false) private RoadmapNode node; + + // ---- 추가 통계 필드 ---- + @Column(name = "average_position") + private Double averagePosition; // 각 노드가 멘토 로드맵에서 평균적으로 위치한 인덱스(1..N) + + @Column(name = "mentor_count") + private Integer mentorCount; // 몇 명의 멘토 로드맵에 등장했는지 (unique mentor count) + + @Column(name = "outgoing_transitions") + private Integer outgoingTransitions; // 이 노드에서 다른 노드로 이동한 총 전이수 + + @Column(name = "incoming_transitions") + private Integer incomingTransitions; // 타 노드에서 이 노드로 들어오는 전이수 + + @Column(name = "transition_counts", columnDefinition = "TEXT") + private String transitionCounts; // (선택) JSON 직렬화: { "T:5":3, "T:7":1 } 형태로 보관 가능 + + @Builder + public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) { + this.stepOrder = stepOrder; + this.weight = weight != null ? weight : 0.0; + this.node = node; + } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java index 4fd29adc..4f121492 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/MentorRoadmap.java @@ -26,7 +26,7 @@ public class MentorRoadmap extends BaseEntity { private Mentor mentor; @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "roadmap_id") + @JoinColumn(name = "roadmap_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) @SQLRestriction("roadmap_type = 'MENTOR'") @OrderBy("stepOrder ASC") private List nodes; diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java index d7225976..d9ef1b20 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/RoadmapNode.java @@ -12,8 +12,8 @@ @Entity @Table(name = "roadmap_node", indexes = { - // 노드 순회용 인덱스 - @Index(name = "idx_roadmap_composite", columnList = "roadmap_id, roadmap_type, step_order"), + // 노드 순회용 인덱스 (level, stepOrder 순 정렬) + @Index(name = "idx_roadmap_composite", columnList = "roadmap_id, roadmap_type, level, step_order"), @Index(name = "idx_roadmap_parent", columnList = "roadmap_id, roadmap_type, parent_id") }) @Getter @@ -36,6 +36,9 @@ public class RoadmapNode extends BaseEntity { @Column(name = "step_order", nullable = false) private int stepOrder = 0; + @Column(name = "level", nullable = false) + private int level = 0; // 트리 깊이 (루트: 0, 자식: 부모 + 1) + @Column(name = "raw_task_name") private String taskName; // Task 이름 표시값(DB에 없는 Task 입력시 입력값 그대로 출력) @@ -52,11 +55,12 @@ public enum RoadmapType { // Builder 패턴 적용된 생성자 @Builder - public RoadmapNode(String taskName, String description, Task task, int stepOrder, long roadmapId, RoadmapType roadmapType) { + public RoadmapNode(String taskName, String description, Task task, int stepOrder, int level, long roadmapId, RoadmapType roadmapType) { this.taskName = taskName; this.description = description; this.task = task; this.stepOrder = stepOrder; + this.level = level; this.roadmapId = roadmapId; this.roadmapType = roadmapType; } @@ -71,9 +75,14 @@ public void addChild(RoadmapNode child) { } this.children.add(child); child.setParent(this); + child.setLevel(this.level + 1); // 부모 level + 1로 자동 설정 } private void setParent(RoadmapNode parent) { this.parent = parent; } + + private void setLevel(int level) { + this.level = level; + } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java new file mode 100644 index 00000000..36d23518 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapRepository.java @@ -0,0 +1,38 @@ +package com.back.domain.roadmap.roadmap.repository; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface JobRoadmapRepository extends JpaRepository { + + @Query("SELECT jr FROM JobRoadmap jr JOIN FETCH jr.job") + List findAllWithJob(); + + @Query(""" + SELECT jr FROM JobRoadmap jr + JOIN FETCH jr.job j + WHERE (:keyword IS NULL OR :keyword = '' OR + LOWER(j.name) LIKE LOWER(CONCAT('%', :keyword, '%')) OR + LOWER(j.description) LIKE LOWER(CONCAT('%', :keyword, '%'))) + """) + Page findAllWithJobAndKeyword(@Param("keyword") String keyword, Pageable pageable); + + @Query(""" + SELECT jr FROM JobRoadmap jr + JOIN FETCH jr.job + LEFT JOIN FETCH jr.nodes n + LEFT JOIN FETCH n.task t + WHERE jr.id = :id + ORDER BY n.level, n.stepOrder""") + Optional findByIdWithJobAndNodes(@Param("id") Long id); + +} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java index 0492ffd0..c81cfd7c 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/MentorRoadmapRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface MentorRoadmapRepository extends JpaRepository { @@ -38,4 +39,17 @@ public interface MentorRoadmapRepository extends JpaRepository findAllByMentorJobIdWithNodes(@Param("jobId") Long jobId); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java new file mode 100644 index 00000000..6dfe4e08 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapService.java @@ -0,0 +1,53 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapListResponse; +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapResponse; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; +import com.back.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class JobRoadmapService { + private final JobRoadmapRepository jobRoadmapRepository; + + public List getAllJobRoadmaps() { + return jobRoadmapRepository.findAllWithJob() + .stream() + .map(this::toListResponse) + .toList(); + } + + public Page getJobRoadmaps(String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + + return jobRoadmapRepository.findAllWithJobAndKeyword(keyword, pageable) + .map(this::toListResponse); + } + + public JobRoadmapResponse getJobRoadmapById(Long id) { + JobRoadmap jobRoadmap = jobRoadmapRepository.findByIdWithJobAndNodes(id) + .orElseThrow(() -> new ServiceException("404", "직업 로드맵을 찾을 수 없습니다.")); + + return JobRoadmapResponse.from(jobRoadmap, jobRoadmap.getJob().getName()); + } + + private JobRoadmapListResponse toListResponse(JobRoadmap jobRoadmap) { + return JobRoadmapListResponse.of( + jobRoadmap.getId(), + jobRoadmap.getJob().getName(), + jobRoadmap.getJob().getDescription() + ); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/initData/RoadmapInitData.java b/back/src/main/java/com/back/global/initData/RoadmapInitData.java index 7f5af7b6..97bc52af 100644 --- a/back/src/main/java/com/back/global/initData/RoadmapInitData.java +++ b/back/src/main/java/com/back/global/initData/RoadmapInitData.java @@ -9,6 +9,9 @@ import com.back.domain.member.mentor.repository.MentorRepository; import com.back.domain.roadmap.roadmap.dto.request.MentorRoadmapSaveRequest; import com.back.domain.roadmap.roadmap.dto.request.RoadmapNodeRequest; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; import com.back.domain.roadmap.roadmap.service.MentorRoadmapService; import com.back.domain.roadmap.task.entity.Task; @@ -22,16 +25,6 @@ import java.util.List; -/** - * RoadmapInitData (풀버전) - * - * - 모든 멘토 로드맵 상단에 '기초' 항목을 추가합니다. - * - Mini Project(권장 실습)는 Task가 아니라 각 노드의 description에 구체적으로 적습니다. - * - Task가 DB에 없으면 자동 생성하도록 createNodeRequest에서 안전장치 적용. - * - * 목적: 청소년(고등학생) 대상의 서비스에서 기초를 우선적으로 확보하고, - * 통합(직업) 로드맵 생성시 의미있는 통계가 나오도록 샘플을 보강합니다. - */ @Configuration @RequiredArgsConstructor @Transactional @@ -44,6 +37,7 @@ public class RoadmapInitData { private final MentorRepository mentorRepository; private final MentorRoadmapService mentorRoadmapService; private final MentorRoadmapRepository mentorRoadmapRepository; + private final JobRoadmapRepository jobRoadmapRepository; @Bean ApplicationRunner baseInitDataApplicationRunner2() { @@ -55,6 +49,7 @@ public void runInitData() { initJobData(); initTaskData(); // 보강된 Task 목록 //initSampleMentorRoadmaps(); // 활성화: 다양한 멘토 로드맵 생성 + //initSampleJobRoadmap(); // 직업 로드맵 조회 API 테스트용 샘플 데이터 } // --- Job 초기화 --- @@ -538,4 +533,140 @@ private RoadmapNodeRequest createNodeRequest(String taskName, int level, int ste stepOrder ); } + + // --- 직업 로드맵 샘플 데이터 생성 (API 테스트용) --- + public void initSampleJobRoadmap() { + if (jobRoadmapRepository.count() > 0) return; + + Job backendJob = jobRepository.findByName("백엔드 개발자") + .orElseThrow(() -> new RuntimeException("백엔드 개발자 직업을 찾을 수 없습니다.")); + + Job frontendJob = jobRepository.findByName("프론트엔드 개발자") + .orElseThrow(() -> new RuntimeException("프론트엔드 개발자 직업을 찾을 수 없습니다.")); + + // 백엔드 개발자 직업 로드맵 생성 (트리 구조로 구성) + JobRoadmap jobRoadmap = JobRoadmap.builder() + .job(backendJob) + .build(); + jobRoadmap = jobRoadmapRepository.save(jobRoadmap); + + // 다건 조회 확인용 프론트엔드 개발자 직업 로드맵 생성 (빈 로드맵) + JobRoadmap frontendRoadmap = JobRoadmap.builder() + .job(frontendJob) + .build(); + + // Task 조회 (이미 생성된 Task들 사용) + Task programmingFundamentals = taskRepository.findByNameIgnoreCase("Programming Fundamentals").orElse(null); + Task git = taskRepository.findByNameIgnoreCase("Git").orElse(null); + Task java = taskRepository.findByNameIgnoreCase("Java").orElse(null); + Task springBoot = taskRepository.findByNameIgnoreCase("Spring Boot").orElse(null); + Task mysql = taskRepository.findByNameIgnoreCase("MySQL").orElse(null); + Task jpa = taskRepository.findByNameIgnoreCase("JPA").orElse(null); + Task docker = taskRepository.findByNameIgnoreCase("Docker").orElse(null); + Task aws = taskRepository.findByNameIgnoreCase("AWS").orElse(null); + + // 트리 구조로 노드 생성 (루트 노드들과 자식 노드들) + + // 루트 노드 1: Programming Fundamentals (level=0, stepOrder=1) + RoadmapNode fundamentalsNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(programmingFundamentals) + .taskName("Programming Fundamentals") + .description("프로그래밍의 기초 개념: 변수, 조건문, 반복문, 함수 등을 이해하고 활용할 수 있습니다.") + .stepOrder(1) + .level(0) + .build(); + + // 루트 노드 2: Git (level=0, stepOrder=2) + RoadmapNode gitNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(git) + .taskName("Git") + .description("버전 관리 시스템으로 코드 히스토리 관리 및 협업을 위한 필수 도구입니다.") + .stepOrder(2) + .level(0) + .build(); + + // Fundamentals의 자식 노드들 + RoadmapNode javaNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(java) + .taskName("Java") + .description("객체지향 프로그래밍 언어로 백엔드 개발의 기초가 되는 언어입니다.") + .stepOrder(1) + .level(1) + .build(); + + RoadmapNode springBootNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(springBoot) + .taskName("Spring Boot") + .description("Java 기반의 웹 애플리케이션 프레임워크로 REST API 개발에 필수입니다.") + .stepOrder(2) + .level(1) + .build(); + + // Java의 자식 노드들 + RoadmapNode mysqlNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(mysql) + .taskName("MySQL") + .description("관계형 데이터베이스로 데이터 저장 및 관리를 위한 기본 기술입니다.") + .stepOrder(1) + .level(2) + .build(); + + RoadmapNode jpaNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(jpa) + .taskName("JPA") + .description("Java 진영의 ORM 기술로 객체와 관계형 데이터베이스를 매핑합니다.") + .stepOrder(2) + .level(2) + .build(); + + // Spring Boot의 자식 노드들 + RoadmapNode dockerNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(docker) + .taskName("Docker") + .description("컨테이너 기술로 애플리케이션 배포 및 환경 관리를 간소화합니다.") + .stepOrder(1) + .level(2) + .build(); + + RoadmapNode awsNode = RoadmapNode.builder() + .roadmapId(jobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(aws) + .taskName("AWS") + .description("클라우드 서비스로 애플리케이션을 확장 가능하게 배포하고 운영합니다.") + .stepOrder(2) + .level(2) + .build(); + + // 트리 구조 연결 (addChild 메서드 사용) + fundamentalsNode.addChild(javaNode); + fundamentalsNode.addChild(springBootNode); + javaNode.addChild(mysqlNode); + javaNode.addChild(jpaNode); + springBootNode.addChild(dockerNode); + springBootNode.addChild(awsNode); + + // 모든 노드를 JobRoadmap에 추가 + jobRoadmap.getNodes().addAll(List.of( + fundamentalsNode, gitNode, javaNode, springBootNode, + mysqlNode, jpaNode, dockerNode, awsNode + )); + + jobRoadmapRepository.save(jobRoadmap); + jobRoadmapRepository.save(frontendRoadmap); // 빈 로드맵 저장 + } } diff --git a/back/src/test/java/com/back/domain/roadmap/roadmap/controller/JobRoadmapControllerTest.java b/back/src/test/java/com/back/domain/roadmap/roadmap/controller/JobRoadmapControllerTest.java new file mode 100644 index 00000000..e27e79b6 --- /dev/null +++ b/back/src/test/java/com/back/domain/roadmap/roadmap/controller/JobRoadmapControllerTest.java @@ -0,0 +1,282 @@ +package com.back.domain.roadmap.roadmap.controller; + +import com.back.domain.job.job.entity.Job; +import com.back.domain.job.job.service.JobService; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; +import com.back.domain.roadmap.task.entity.Task; +import com.back.domain.roadmap.task.service.TaskService; +import org.junit.jupiter.api.BeforeEach; +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.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@WithMockUser +class JobRoadmapControllerTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private JobRoadmapRepository jobRoadmapRepository; + + @Autowired + private JobService jobService; + + @Autowired + private TaskService taskService; + + private Job testJob1; + private Job testJob2; + private JobRoadmap testJobRoadmap1; + private JobRoadmap testJobRoadmap2; + private Task javaTask; + private Task springTask; + private Task reactTask; + + @BeforeEach + void setUp() { + setupTestData(); + } + + private void setupTestData() { + // 테스트용 Job 생성 + long timestamp = System.currentTimeMillis(); + testJob1 = jobService.create("테스트 백엔드_" + timestamp, "테스트용 서버 사이드 개발자"); + testJob2 = jobService.create("테스트 프론트엔드_" + timestamp, "테스트용 클라이언트 사이드 개발자"); + + // 테스트용 Task 생성 + javaTask = taskService.create("TestJava_" + timestamp); + springTask = taskService.create("TestSpring_" + timestamp); + reactTask = taskService.create("TestReact_" + timestamp); + + // 테스트용 JobRoadmap 1 생성 (백엔드) + testJobRoadmap1 = JobRoadmap.builder() + .job(testJob1) + .build(); + testJobRoadmap1 = jobRoadmapRepository.save(testJobRoadmap1); + + // 백엔드 로드맵 노드 생성 + RoadmapNode javaNode = RoadmapNode.builder() + .roadmapId(testJobRoadmap1.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(javaTask) + .taskName(javaTask.getName()) + .description("Java 프로그래밍 언어") + .stepOrder(1) + .level(0) + .build(); + + RoadmapNode springNode = RoadmapNode.builder() + .roadmapId(testJobRoadmap1.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(springTask) + .taskName(springTask.getName()) + .description("Spring Boot 프레임워크") + .stepOrder(1) + .level(1) + .build(); + javaNode.addChild(springNode); + + testJobRoadmap1.getNodes().add(javaNode); + testJobRoadmap1.getNodes().add(springNode); + jobRoadmapRepository.save(testJobRoadmap1); + + // 테스트용 JobRoadmap 2 생성 (프론트엔드) + testJobRoadmap2 = JobRoadmap.builder() + .job(testJob2) + .build(); + testJobRoadmap2 = jobRoadmapRepository.save(testJobRoadmap2); + + RoadmapNode reactNode = RoadmapNode.builder() + .roadmapId(testJobRoadmap2.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(reactTask) + .taskName(reactTask.getName()) + .description("React 라이브러리") + .stepOrder(1) + .level(0) + .build(); + + testJobRoadmap2.getNodes().add(reactNode); + jobRoadmapRepository.save(testJobRoadmap2); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 기본 페이징") + void getJobRoadmaps_DefaultPaging() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("직업 로드맵 목록 조회 성공")) + .andExpect(jsonPath("$.data.jobRoadmaps").isArray()) + .andExpect(jsonPath("$.data.jobRoadmaps.length()").value(2)) + .andExpect(jsonPath("$.data.totalElements").value(2)) + .andExpect(jsonPath("$.data.totalPage").value(1)) + .andExpect(jsonPath("$.data.currentPage").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 페이지 크기 지정") + void getJobRoadmaps_CustomPageSize() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps") + .param("page", "0") + .param("size", "1") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.jobRoadmaps.length()").value(1)) + .andExpect(jsonPath("$.data.totalElements").value(2)) + .andExpect(jsonPath("$.data.totalPage").value(2)) + .andExpect(jsonPath("$.data.currentPage").value(0)); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 키워드 검색") + void getJobRoadmaps_WithKeyword() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps") + .param("keyword", "백엔드") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.jobRoadmaps.length()").value(1)) + .andExpect(jsonPath("$.data.jobRoadmaps[0].jobName").value(testJob1.getName())); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 존재하지 않는 키워드") + void getJobRoadmaps_NoResultsKeyword() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps") + .param("keyword", "존재하지않는키워드") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.jobRoadmaps.length()").value(0)) + .andExpect(jsonPath("$.data.totalElements").value(0)); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 응답 필드 검증") + void getJobRoadmaps_ResponseFields() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.jobRoadmaps[0].id").exists()) + .andExpect(jsonPath("$.data.jobRoadmaps[0].jobName").exists()) + .andExpect(jsonPath("$.data.jobRoadmaps[0].jobDescription").exists()) + .andExpect(jsonPath("$.data.jobRoadmaps[0].id").isNumber()) + .andExpect(jsonPath("$.data.jobRoadmaps[0].jobName").isString()) + .andExpect(jsonPath("$.data.jobRoadmaps[0].jobDescription").isString()); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 성공") + void getJobRoadmapById_Success() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps/{id}", testJobRoadmap1.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("직업 로드맵 상세 조회 성공")) + .andExpect(jsonPath("$.data.id").value(testJobRoadmap1.getId())) + .andExpect(jsonPath("$.data.jobId").value(testJob1.getId())) + .andExpect(jsonPath("$.data.jobName").value(testJob1.getName())) + .andExpect(jsonPath("$.data.totalNodeCount").value(2)); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 트리 구조 검증") + void getJobRoadmapById_TreeStructure() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps/{id}", testJobRoadmap1.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + // 루트 노드 검증 + .andExpect(jsonPath("$.data.nodes.length()").value(1)) + .andExpect(jsonPath("$.data.nodes[0].taskName").value(javaTask.getName())) + .andExpect(jsonPath("$.data.nodes[0].level").value(0)) + .andExpect(jsonPath("$.data.nodes[0].stepOrder").value(1)) + .andExpect(jsonPath("$.data.nodes[0].parentId").isEmpty()) + // 자식 노드 검증 + .andExpect(jsonPath("$.data.nodes[0].children.length()").value(1)) + .andExpect(jsonPath("$.data.nodes[0].children[0].taskName").value(springTask.getName())) + .andExpect(jsonPath("$.data.nodes[0].children[0].level").value(1)) + .andExpect(jsonPath("$.data.nodes[0].children[0].stepOrder").value(1)) + .andExpect(jsonPath("$.data.nodes[0].children[0].parentId").exists()); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 존재하지 않는 ID") + void getJobRoadmapById_NotFound() throws Exception { + // given + Long nonExistentId = 999L; + + // when & then + mvc.perform(get("/job-roadmaps/{id}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 모든 응답 필드 검증") + void getJobRoadmapById_AllResponseFields() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps/{id}", testJobRoadmap1.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").isNumber()) + .andExpect(jsonPath("$.data.jobId").isNumber()) + .andExpect(jsonPath("$.data.jobName").isString()) + .andExpect(jsonPath("$.data.nodes").isArray()) + .andExpect(jsonPath("$.data.totalNodeCount").isNumber()) + .andExpect(jsonPath("$.data.createdDate").exists()) + .andExpect(jsonPath("$.data.modifiedDate").exists()); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 노드 필드 검증") + void getJobRoadmapById_NodeFields() throws Exception { + // when & then + mvc.perform(get("/job-roadmaps/{id}", testJobRoadmap1.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nodes[0].id").isNumber()) + .andExpect(jsonPath("$.data.nodes[0].taskId").isNumber()) + .andExpect(jsonPath("$.data.nodes[0].taskName").isString()) + .andExpect(jsonPath("$.data.nodes[0].description").isString()) + .andExpect(jsonPath("$.data.nodes[0].stepOrder").isNumber()) + .andExpect(jsonPath("$.data.nodes[0].level").isNumber()) + .andExpect(jsonPath("$.data.nodes[0].isLinkedToTask").isBoolean()) + .andExpect(jsonPath("$.data.nodes[0].children").isArray()); + } +} \ No newline at end of file diff --git a/back/src/test/java/com/back/domain/roadmap/roadmap/service/JobRoadmapServiceTest.java b/back/src/test/java/com/back/domain/roadmap/roadmap/service/JobRoadmapServiceTest.java new file mode 100644 index 00000000..a303d69e --- /dev/null +++ b/back/src/test/java/com/back/domain/roadmap/roadmap/service/JobRoadmapServiceTest.java @@ -0,0 +1,266 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.job.job.entity.Job; +import com.back.domain.job.job.service.JobService; +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapListResponse; +import com.back.domain.roadmap.roadmap.dto.response.JobRoadmapResponse; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; +import com.back.domain.roadmap.task.entity.Task; +import com.back.domain.roadmap.task.service.TaskService; +import com.back.global.exception.ServiceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +class JobRoadmapServiceTest { + + @Autowired + private JobRoadmapService jobRoadmapService; + + @Autowired + private JobRoadmapRepository jobRoadmapRepository; + + @Autowired + private JobService jobService; + + @Autowired + private TaskService taskService; + + private Job testJob; + private Task testTask1; + private Task testTask2; + private JobRoadmap testJobRoadmap; + + @BeforeEach + void setUp() { + // 기존 Job 조회 또는 새로 생성 (UNIQUE 제약 조건 회피) + try { + testJob = jobService.create("테스트용 백엔드 개발자", "테스트용 서버 사이드 개발을 담당하는 개발자"); + } catch (Exception e) { + // 이미 존재할 경우 조회로 대체하거나 다른 이름 사용 + testJob = jobService.create("테스트 백엔드 " + System.currentTimeMillis(), "테스트용 서버 사이드 개발자"); + } + + // 테스트용 Task 생성 (UNIQUE 제약 조건 회피) + try { + testTask1 = taskService.create("Test Java"); + testTask2 = taskService.create("Test Spring Boot"); + } catch (Exception e) { + // 이미 존재할 경우 타임스탬프 추가 + long timestamp = System.currentTimeMillis(); + testTask1 = taskService.create("Java_" + timestamp); + testTask2 = taskService.create("SpringBoot_" + timestamp); + } + + // 테스트용 JobRoadmap 생성 + testJobRoadmap = JobRoadmap.builder() + .job(testJob) + .build(); + testJobRoadmap = jobRoadmapRepository.save(testJobRoadmap); + + // 테스트용 RoadmapNode 생성 (트리 구조) + RoadmapNode rootNode = RoadmapNode.builder() + .roadmapId(testJobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(testTask1) + .taskName(testTask1.getName()) + .description("Java 프로그래밍 언어") + .stepOrder(1) + .level(0) + .build(); + + RoadmapNode childNode = RoadmapNode.builder() + .roadmapId(testJobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(testTask2) + .taskName(testTask2.getName()) + .description("Spring Boot 프레임워크") + .stepOrder(1) + .level(1) + .build(); + rootNode.addChild(childNode); + + testJobRoadmap.getNodes().add(rootNode); + testJobRoadmap.getNodes().add(childNode); + jobRoadmapRepository.save(testJobRoadmap); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 전체 조회") + void getAllJobRoadmaps() { + // when + List result = jobRoadmapService.getAllJobRoadmaps(); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).hasSize(1); + + JobRoadmapListResponse response = result.get(0); + assertThat(response.id()).isEqualTo(testJobRoadmap.getId()); + assertThat(response.jobName()).isEqualTo(testJob.getName()); + assertThat(response.jobDescription()).isEqualTo(testJob.getDescription()); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 페이징 및 키워드 검색") + void getJobRoadmaps_WithPagingAndKeyword() { + // given + String keyword = "백엔드"; + int page = 0; + int size = 10; + + // when + Page result = jobRoadmapService.getJobRoadmaps(keyword, page, size); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getSize()).isEqualTo(10); + + JobRoadmapListResponse response = result.getContent().get(0); + assertThat(response.id()).isEqualTo(testJobRoadmap.getId()); + assertThat(response.jobName()).isEqualTo(testJob.getName()); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - 키워드 검색 결과 없음") + void getJobRoadmaps_NoResults() { + // given + String keyword = "존재하지않는키워드"; + int page = 0; + int size = 10; + + // when + Page result = jobRoadmapService.getJobRoadmaps(keyword, page, size); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("직업 로드맵 다건 조회 - null 키워드") + void getJobRoadmaps_NullKeyword() { + // given + String keyword = null; + int page = 0; + int size = 10; + + // when + Page result = jobRoadmapService.getJobRoadmaps(keyword, page, size); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 성공") + void getJobRoadmapById_Success() { + // when + JobRoadmapResponse result = jobRoadmapService.getJobRoadmapById(testJobRoadmap.getId()); + + // then + assertThat(result.id()).isEqualTo(testJobRoadmap.getId()); + assertThat(result.jobId()).isEqualTo(testJob.getId()); + assertThat(result.jobName()).isEqualTo(testJob.getName()); + assertThat(result.totalNodeCount()).isEqualTo(2); + + // 트리 구조 검증 + assertThat(result.nodes()).hasSize(1); // 루트 노드 1개 + + // 루트 노드 검증 + var rootNode = result.nodes().get(0); + assertThat(rootNode.taskName()).isEqualTo(testTask1.getName()); + assertThat(rootNode.level()).isEqualTo(0); + assertThat(rootNode.stepOrder()).isEqualTo(1); + assertThat(rootNode.parentId()).isNull(); + + // 자식 노드 검증 + assertThat(rootNode.children()).hasSize(1); + var childNode = rootNode.children().get(0); + assertThat(childNode.taskName()).isEqualTo(testTask2.getName()); + assertThat(childNode.level()).isEqualTo(1); + assertThat(childNode.stepOrder()).isEqualTo(1); + assertThat(childNode.parentId()).isEqualTo(rootNode.id()); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 존재하지 않는 ID") + void getJobRoadmapById_NotFound() { + // given + Long nonExistentId = 999L; + + // when & then + assertThatThrownBy(() -> jobRoadmapService.getJobRoadmapById(nonExistentId)) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("직업 로드맵을 찾을 수 없습니다"); + } + + @Test + @DisplayName("직업 로드맵 단건 조회 - 트리 구조 정렬 검증") + void getJobRoadmapById_TreeStructureSorting() { + // given - 추가 노드들로 복잡한 트리 구조 생성 + long timestamp = System.currentTimeMillis(); + Task testTask3 = taskService.create("MySQL_" + timestamp); + Task testTask4 = taskService.create("Git_" + timestamp); + + // 같은 레벨에 여러 노드 추가 + RoadmapNode rootNode2 = RoadmapNode.builder() + .roadmapId(testJobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(testTask4) + .taskName(testTask4.getName()) + .description("버전 관리 시스템") + .stepOrder(2) + .level(0) + .build(); + + RoadmapNode childNode2 = RoadmapNode.builder() + .roadmapId(testJobRoadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.JOB) + .task(testTask3) + .taskName(testTask3.getName()) + .description("관계형 데이터베이스") + .stepOrder(2) + .level(1) + .build(); + testJobRoadmap.getNodes().get(0).addChild(childNode2); // Java 노드의 자식 + + testJobRoadmap.getNodes().add(rootNode2); + testJobRoadmap.getNodes().add(childNode2); + jobRoadmapRepository.save(testJobRoadmap); + + // when + JobRoadmapResponse result = jobRoadmapService.getJobRoadmapById(testJobRoadmap.getId()); + + // then + assertThat(result.totalNodeCount()).isEqualTo(4); + assertThat(result.nodes()).hasSize(2); // 루트 노드 2개 + + // 루트 노드 정렬 검증 (level -> stepOrder 순) + assertThat(result.nodes().get(0).taskName()).isEqualTo(testTask1.getName()); // level=0, stepOrder=1 + assertThat(result.nodes().get(1).taskName()).isEqualTo(testTask4.getName()); // level=0, stepOrder=2 + + // Java 노드의 자식 노드 정렬 검증 + var javaNode = result.nodes().get(0); + assertThat(javaNode.children()).hasSize(2); + assertThat(javaNode.children().get(0).taskName()).isEqualTo(testTask2.getName()); // stepOrder=1 + assertThat(javaNode.children().get(1).taskName()).isEqualTo(testTask3.getName()); // stepOrder=2 + } +} \ No newline at end of file