diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java new file mode 100644 index 0000000..9704240 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java @@ -0,0 +1,49 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "관리자 - 커리큘럼 세트 API") +@SecurityRequirement(name = "JWT") +public interface AdminCurriculumApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "커리큘럼 세트 이름 수정") + AdminCurriculumSummaryResponse updateCurriculum(@PathVariable Long id, + @RequestBody @Valid CurriculumUpdateRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "커리큘럼 세트 삭제", description = "soft delete — 하위 주차·토픽·세부항목은 cascade로 물리 삭제된다.") + ResponseEntity deleteCurriculum(@PathVariable Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "공개/숨김", description = "공개로 지정하면 같은 트랙의 기존 공개 세트가 자동으로 비공개된다(AC-2.1).") + ResponseEntity publish(@PathVariable Long id, @RequestBody @Valid PublishRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java new file mode 100644 index 0000000..684c8b2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java @@ -0,0 +1,45 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumService; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/curriculums") +@RequiredArgsConstructor +public class AdminCurriculumController implements AdminCurriculumApi { + + private final AdminCurriculumService adminCurriculumService; + + @Override + @PutMapping("/{id}") + public AdminCurriculumSummaryResponse updateCurriculum(@PathVariable Long id, + @Valid @RequestBody CurriculumUpdateRequest request) { + return adminCurriculumService.updateCurriculum(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteCurriculum(@PathVariable Long id) { + adminCurriculumService.deleteCurriculum(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminCurriculumService.publish(id, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java new file mode 100644 index 0000000..c665508 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java @@ -0,0 +1,40 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "관리자 - 커리큘럼 세트 API") +@SecurityRequirement(name = "JWT") +public interface AdminCurriculumSetApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "커리큘럼 세트 목록") + List getCurriculums(@PathVariable Long trackPageId); + + @ApiResponses(value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "커리큘럼 세트 생성", description = "생성 직후에는 비공개다.") + ResponseEntity createCurriculum(@PathVariable Long trackPageId, + @RequestBody @Valid CurriculumCreateRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetController.java new file mode 100644 index 0000000..8146989 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetController.java @@ -0,0 +1,38 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/track-pages/{trackPageId}/curriculums") +@RequiredArgsConstructor +public class AdminCurriculumSetController implements AdminCurriculumSetApi { + + private final AdminCurriculumService adminCurriculumService; + + @Override + @GetMapping + public List getCurriculums(@PathVariable Long trackPageId) { + return adminCurriculumService.getCurriculums(trackPageId); + } + + @Override + @PostMapping + public ResponseEntity createCurriculum( + @PathVariable Long trackPageId, @Valid @RequestBody CurriculumCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(adminCurriculumService.createCurriculum(trackPageId, 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 new file mode 100644 index 0000000..801925c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * sourceCurriculumId는 T-13(세트 복제)에서 추가된다. 지금은 순수 생성만 지원한다. + */ +public record CurriculumCreateRequest( + @NotBlank + @Size(max = 50) + String name +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumUpdateRequest.java new file mode 100644 index 0000000..44a404d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumUpdateRequest.java @@ -0,0 +1,11 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CurriculumUpdateRequest( + @NotBlank + @Size(max = 50) + String name +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumSummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumSummaryResponse.java new file mode 100644 index 0000000..f037766 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumSummaryResponse.java @@ -0,0 +1,19 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; + +/** + * 세트 목록용 요약. 주차·토픽 수는 아직 없다 — 그 개념이 생기는 T-11에서 이 레코드에 + * 필드를 추가한다. + */ +public record AdminCurriculumSummaryResponse( + Long id, + String name, + boolean isPublished, + int displayOrder +) { + public static AdminCurriculumSummaryResponse from(Curriculum curriculum) { + return new AdminCurriculumSummaryResponse( + curriculum.getId(), curriculum.getName(), curriculum.isPublished(), curriculum.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/Curriculum.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/Curriculum.java new file mode 100644 index 0000000..e04381f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/Curriculum.java @@ -0,0 +1,63 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +/** + * 트랙 페이지의 커리큘럼 세트("비기너", "심화"). 트랙당 공개 세트는 하나뿐이다 + * (INV-2, {@code uq_curriculum_published} partial unique index). + */ +@Getter +@Entity +@Table(name = "curriculum") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Curriculum extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_page_id", nullable = false, updatable = false) + private TrackPage trackPage; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + private Curriculum(TrackPage trackPage, String name, boolean published, int displayOrder) { + this.trackPage = trackPage; + this.name = name; + this.published = published; + this.displayOrder = displayOrder; + } + + public void rename(String name) { + this.name = name; + } + + public void updatePublished(boolean published) { + this.published = published; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumRepository.java new file mode 100644 index 0000000..c946efb --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumRepository.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.repository; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface CurriculumRepository extends JpaRepository { + + List findAllByTrackPage_IdOrderByDisplayOrderAsc(Long trackPageId); + + Optional findByTrackPage_IdAndPublishedTrue(Long trackPageId); + + /** + * AC-2.1: 새 세트를 공개하기 전에, 같은 트랙의 다른 공개 세트를 먼저 비공개로 내린다. + * 벌크 UPDATE로 즉시 실행되므로, 뒤이어 대상 엔티티를 공개로 바꿔도 + * uq_curriculum_published(partial unique index)에 걸리지 않는다. + */ + @Modifying + @Transactional + @Query("update Curriculum c set c.published = false " + + "where c.trackPage.id = :trackPageId and c.id <> :excludeId and c.published = true") + int unpublishOthers(@Param("trackPageId") Long trackPageId, @Param("excludeId") Long excludeId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java new file mode 100644 index 0000000..e4d303e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java @@ -0,0 +1,77 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.service; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumException; +import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumExceptionType; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCurriculumService { + + private final CurriculumRepository curriculumRepository; + private final TrackPageRepository trackPageRepository; + + public List getCurriculums(Long trackPageId) { + return curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream() + .map(AdminCurriculumSummaryResponse::from) + .toList(); + } + + @Transactional + public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, CurriculumCreateRequest request) { + TrackPage trackPage = trackPageRepository.findById(trackPageId) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_NOT_FOUND)); + + int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + Curriculum curriculum = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage) + .name(request.name()) + .published(false) + .displayOrder(displayOrder) + .build()); + return AdminCurriculumSummaryResponse.from(curriculum); + } + + @Transactional + public AdminCurriculumSummaryResponse updateCurriculum(Long id, CurriculumUpdateRequest request) { + Curriculum curriculum = findOrThrow(id); + curriculum.rename(request.name()); + return AdminCurriculumSummaryResponse.from(curriculum); + } + + @Transactional + public void deleteCurriculum(Long id) { + findOrThrow(id).delete(Instant.now()); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + Curriculum curriculum = findOrThrow(id); + if (request.isPublished()) { + // AC-2.1: 벌크 UPDATE로 다른 공개 세트를 먼저 내려야, 뒤이은 dirty-check 반영 시 + // uq_curriculum_published(partial unique index)에 걸리지 않는다. + curriculumRepository.unpublishOthers(curriculum.getTrackPage().getId(), id); + } + curriculum.updatePublished(request.isPublished()); + } + + private Curriculum findOrThrow(Long id) { + return curriculumRepository.findById(id) + .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND)); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumIntegrationTest.java new file mode 100644 index 0000000..ea4a257 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumIntegrationTest.java @@ -0,0 +1,143 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +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.repository.CurriculumRepository; +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 AdminCurriculumIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private CurriculumRepository curriculumRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackPage trackPage; + 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()); + + 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("세트를 생성하면 비공개 상태로 만들어진다") + void 세트_생성은_비공개_상태다() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"비기너\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("비기너")) + .andExpect(jsonPath("$.isPublished").value(false)); + } + + @Test + @DisplayName("AC-2.1 세트를 공개하면 같은 트랙의 기존 공개 세트가 자동으로 비공개된다") + void 공개하면_기존_공개_세트가_자동으로_내려간다() throws Exception { + Curriculum a = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("A").published(true).displayOrder(0).build()); + Curriculum b = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("B").published(false).displayOrder(1).build()); + + mockMvc.perform(patch("/v1/admin/curriculums/" + b.getId() + "/publish") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"isPublished\":true}")) + .andExpect(status().isNoContent()); + + assertThat(curriculumRepository.findById(a.getId()).orElseThrow().isPublished()).isFalse(); + assertThat(curriculumRepository.findById(b.getId()).orElseThrow().isPublished()).isTrue(); + } + + @Test + @DisplayName("이름 수정과 soft delete가 반영된다") + void 이름_수정과_삭제() throws Exception { + Curriculum curriculum = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("초안").published(false).displayOrder(0).build()); + + mockMvc.perform(put("/v1/admin/curriculums/" + curriculum.getId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"비기너\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("비기너")); + + mockMvc.perform(delete("/v1/admin/curriculums/" + curriculum.getId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } +}