From c2494d504207aad13ea2585a304cd69fe17dba8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Wed, 19 Aug 2026 22:26:15 +0900 Subject: [PATCH] =?UTF-8?q?T-13=20=EC=BB=A4=EB=A6=AC=ED=81=98=EB=9F=BC=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EB=B3=B5=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/admin/track-pages/{id}/curriculums에 sourceCurriculumId를 주면 트리 전체(주차·토픽·세부항목)를 복제한다. 다른 트랙의 세트도 원본으로 쓸 수 있다 — 시안에 "다른 트랙 커리큘럼 복제해서 시작 가능"이 명시되어 있다. - CurriculumCreateRequest: sourceCurriculumId 필드 추가. 복제 시 name을 비우면 원본 이름을 그대로 쓰고, 복제가 아닌데 이름이 없으면 NAME_REQUIRED(400) - AdminCurriculumTreeService.cloneTree — 원본의 주차>토픽>세부항목을 순회하며 새 id로 저장. display_order는 원본과 동일하게 유지(개수·순서 보존, AC-2.8) - 복제본은 항상 비공개로 생성된다(기존 로직 그대로 — published(false)) 테스트: AdminCurriculumCloneIntegrationTest(신규) — - AC-2.8 복제 후 주차 2개·토픽 순서·세부항목 순서가 원본과 동일, 복제본은 비공개, 원본은 영향받지 않음 - 이름 없이 복제하면 원본 이름 사용 - 복제가 아닌데 이름이 없으면 400 ./gradlew test 3회 연속 전체 통과(74개, 실패 0). Refs #25 --- 이걸로 P2(커리큘럼) 완료 — T-10~T-13, PR #64~#67. --- .../controller/AdminCurriculumSetApi.java | 4 +- .../dto/request/CurriculumCreateRequest.java | 14 +- .../exception/CurriculumExceptionType.java | 1 + .../service/AdminCurriculumService.java | 18 +- .../service/AdminCurriculumTreeService.java | 35 ++++ .../AdminCurriculumCloneIntegrationTest.java | 174 ++++++++++++++++++ 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java index c665508..cf3a75f 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java @@ -34,7 +34,9 @@ public interface AdminCurriculumSetApi { @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), }) - @Operation(summary = "커리큘럼 세트 생성", description = "생성 직후에는 비공개다.") + @Operation(summary = "커리큘럼 세트 생성", + description = "생성 직후에는 항상 비공개다. sourceCurriculumId를 주면 다른 세트(다른 트랙도 가능)를 " + + "트리 전체까지 복제한다(AC-2.8).") ResponseEntity createCurriculum(@PathVariable Long trackPageId, @RequestBody @Valid CurriculumCreateRequest request); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java index 801925c..c9914a9 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java @@ -1,14 +1,20 @@ package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; /** - * sourceCurriculumId는 T-13(세트 복제)에서 추가된다. 지금은 순수 생성만 지원한다. + * sourceCurriculumId를 주면 그 세트를 트리 전체(주차·토픽·세부항목)까지 복제한다(T-13, + * FR-2.9). 그 경우 name을 비워 두면 원본 이름을 그대로 쓴다 — 복제 직후 화면에서 이름을 + * 바꿀 수 있으므로 별도 입력을 강제하지 않는다. sourceCurriculumId가 없으면 name이 + * 필수다(서비스 레이어에서 검증). */ public record CurriculumCreateRequest( - @NotBlank @Size(max = 50) - String name + String name, + + Long sourceCurriculumId ) { + public boolean isClone() { + return sourceCurriculumId != null; + } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java index 7dd0c3d..b5613d3 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java @@ -9,6 +9,7 @@ public enum CurriculumExceptionType implements BcsdExceptionType { WEEK_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주차입니다."), TOPIC_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 토픽입니다."), WEEK_RANGE_INVALID(HttpStatus.BAD_REQUEST, "주차 범위가 올바르지 않습니다."), + NAME_REQUIRED(HttpStatus.BAD_REQUEST, "세트 이름은 필수입니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java index e4d303e..eee5089 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java @@ -25,6 +25,7 @@ public class AdminCurriculumService { private final CurriculumRepository curriculumRepository; private final TrackPageRepository trackPageRepository; + private final AdminCurriculumTreeService adminCurriculumTreeService; public List getCurriculums(Long trackPageId) { return curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream() @@ -37,13 +38,28 @@ public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, Curricu TrackPage trackPage = trackPageRepository.findById(trackPageId) .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_NOT_FOUND)); + String name = request.name(); + Curriculum source = null; + if (request.isClone()) { + source = findOrThrow(request.sourceCurriculumId()); + if (name == null || name.isBlank()) { + name = source.getName(); + } + } else if (name == null || name.isBlank()) { + throw new CurriculumException(CurriculumExceptionType.NAME_REQUIRED); + } + int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); Curriculum curriculum = curriculumRepository.save(Curriculum.builder() .trackPage(trackPage) - .name(request.name()) + .name(name) .published(false) .displayOrder(displayOrder) .build()); + + if (source != null) { + adminCurriculumTreeService.cloneTree(source, curriculum); + } return AdminCurriculumSummaryResponse.from(curriculum); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java index 8466918..4277eff 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java @@ -155,6 +155,41 @@ public List replaceDetails(Long topicId, TopicDetailsReplaceRequest requ return saved.stream().map(CurriculumTopicDetail::getContent).toList(); } + /** + * source의 주차 > 토픽 > 세부항목을 전부 target 아래로 복제한다(AC-2.8). id는 + * 새로 발급되지만 개수·순서(display_order)는 원본과 동일하게 유지한다. + */ + @Transactional + public void cloneTree(Curriculum source, Curriculum target) { + for (CurriculumWeek sourceWeek : curriculumWeekRepository + .findAllByCurriculum_IdOrderByDisplayOrderAsc(source.getId())) { + CurriculumWeek clonedWeek = curriculumWeekRepository.save(CurriculumWeek.builder() + .curriculum(target) + .weekFrom(sourceWeek.getWeekFrom()) + .weekTo(sourceWeek.getWeekTo()) + .displayOrder(sourceWeek.getDisplayOrder()) + .build()); + + for (CurriculumTopic sourceTopic : curriculumTopicRepository + .findAllByWeek_IdOrderByDisplayOrderAsc(sourceWeek.getId())) { + CurriculumTopic clonedTopic = curriculumTopicRepository.save(CurriculumTopic.builder() + .week(clonedWeek) + .title(sourceTopic.getTitle()) + .displayOrder(sourceTopic.getDisplayOrder()) + .build()); + + for (CurriculumTopicDetail sourceDetail : curriculumTopicDetailRepository + .findAllByTopic_IdOrderByDisplayOrderAsc(sourceTopic.getId())) { + curriculumTopicDetailRepository.save(CurriculumTopicDetail.builder() + .topic(clonedTopic) + .content(sourceDetail.getContent()) + .displayOrder(sourceDetail.getDisplayOrder()) + .build()); + } + } + } + } + private void validateRange(WeekRequest request) { if (request.weekTo() != null && request.weekTo() < request.weekFrom()) { throw new CurriculumException(CurriculumExceptionType.WEEK_RANGE_INVALID); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java new file mode 100644 index 0000000..cf5fd3c --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java @@ -0,0 +1,174 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopic; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopicDetail; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumWeek; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumTopicDetailRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumTopicRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumWeekRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminCurriculumCloneIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private CurriculumRepository curriculumRepository; + + @Autowired + private CurriculumWeekRepository curriculumWeekRepository; + + @Autowired + private CurriculumTopicRepository curriculumTopicRepository; + + @Autowired + private CurriculumTopicDetailRepository curriculumTopicDetailRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackPage trackPage; + private Curriculum source; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + + source = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("비기너").published(true).displayOrder(0).build()); + CurriculumWeek week1 = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(source).weekFrom(1).displayOrder(0).build()); + CurriculumWeek week2 = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(source).weekFrom(13).weekTo(16).displayOrder(1).build()); + CurriculumTopic topic1 = curriculumTopicRepository.save( + CurriculumTopic.builder().week(week1).title("웹 동작 방식").displayOrder(0).build()); + curriculumTopicRepository.save( + CurriculumTopic.builder().week(week1).title("HTTP").displayOrder(1).build()); + curriculumTopicRepository.save( + CurriculumTopic.builder().week(week2).title("PROJECT").displayOrder(0).build()); + curriculumTopicDetailRepository.save( + CurriculumTopicDetail.builder().topic(topic1).content("클라이언트-서버").displayOrder(0).build()); + curriculumTopicDetailRepository.save( + CurriculumTopicDetail.builder().topic(topic1).content("DNS").displayOrder(1).build()); + + Member admin = memberRepository.save(Member.builder() + .studentNumber("20231111") + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("관리자") + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("admin@bcsd.club") + .status(MemberStatus.ACTIVE) + .build()); + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"20231111\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + adminToken = objectMapper.readTree(body).get("accessToken").asText(); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + } + + @Test + @DisplayName("AC-2.8 세트를 복제하면 주차·토픽·세부항목 개수와 순서가 원본과 같고 복제본은 비공개다") + void 세트를_복제하면_트리_전체가_복사되고_비공개다() throws Exception { + String body = mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sourceCurriculumId\":%d}".formatted(source.getId()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("비기너")) + .andExpect(jsonPath("$.isPublished").value(false)) + .andReturn().getResponse().getContentAsString(); + long cloneId = objectMapper.readTree(body).get("id").asLong(); + + var clonedWeeks = curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(cloneId); + assertThat(clonedWeeks).hasSize(2); + assertThat(clonedWeeks.get(0).getWeekFrom()).isEqualTo(1); + assertThat(clonedWeeks.get(1).getWeekFrom()).isEqualTo(13); + assertThat(clonedWeeks.get(1).getWeekTo()).isEqualTo(16); + + var clonedTopicsOfWeek1 = curriculumTopicRepository + .findAllByWeek_IdOrderByDisplayOrderAsc(clonedWeeks.get(0).getId()); + assertThat(clonedTopicsOfWeek1).hasSize(2); + assertThat(clonedTopicsOfWeek1.get(0).getTitle()).isEqualTo("웹 동작 방식"); + + var clonedDetails = curriculumTopicDetailRepository + .findAllByTopic_IdOrderByDisplayOrderAsc(clonedTopicsOfWeek1.get(0).getId()); + assertThat(clonedDetails).hasSize(2); + assertThat(clonedDetails.get(0).getContent()).isEqualTo("클라이언트-서버"); + assertThat(clonedDetails.get(1).getContent()).isEqualTo("DNS"); + + // 원본은 그대로다 — 복제가 원본을 건드리지 않는다 + assertThat(curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(source.getId())).hasSize(2); + } + + @Test + @DisplayName("이름 없이 복제하면 원본 이름을 그대로 쓴다") + void 이름_없이_복제하면_원본_이름을_쓴다() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sourceCurriculumId\":%d,\"name\":\"\"}".formatted(source.getId()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("비기너")); + } + + @Test + @DisplayName("복제가 아닌데 이름이 없으면 400이다") + void 복제가_아닌데_이름이_없으면_400() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } +}