diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java index f6144ee2..f3247193 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java @@ -25,7 +25,7 @@ public class MentoringController { private final Rq rq; @GetMapping - @Operation(summary = "멘토링 목록 조회") + @Operation(summary = "멘토링 목록 조회", description = "멘토링 목록을 조회합니다") public RsData getMentorings( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @@ -42,7 +42,7 @@ public RsData getMentorings( } @GetMapping("/{mentoringId}") - @Operation(summary = "멘토링 상세 조회") + @Operation(summary = "멘토링 상세 조회", description = "특정 멘토링을 상세 조회합니다.") public RsData getMentoring( @PathVariable Long mentoringId ) { @@ -57,7 +57,7 @@ public RsData getMentoring( @PostMapping @PreAuthorize("hasRole('MENTOR')") - @Operation(summary = "멘토링 생성") + @Operation(summary = "멘토링 생성", description = "멘토링을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") public RsData createMentoring( @RequestBody @Valid MentoringRequest reqDto ) { @@ -72,7 +72,7 @@ public RsData createMentoring( } @PutMapping("/{mentoringId}") - @Operation(summary = "멘토링 수정") + @Operation(summary = "멘토링 수정", description = "멘토링을 수정합니다. 멘토링 작성자만 접근할 수 있습니다.") public RsData updateMentoring( @PathVariable Long mentoringId, @RequestBody @Valid MentoringRequest reqDto @@ -88,7 +88,7 @@ public RsData updateMentoring( } @DeleteMapping("/{mentoringId}") - @Operation(summary = "멘토링 삭제") + @Operation(summary = "멘토링 삭제", description = "멘토링을 삭제합니다. 멘토링 작성자만 접근할 수 있습니다.") public RsData deleteMentoring( @PathVariable Long mentoringId ) { diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java b/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java index 4a4db327..8749e8a8 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/entity/Reservation.java @@ -46,5 +46,17 @@ public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, St this.mentorSlot = mentorSlot; this.preQuestion = preQuestion; this.status = ReservationStatus.PENDING; + + // 양방향 동기화 + mentorSlot.setReservation(this); + } + + public void updateStatus(ReservationStatus status) { + this.status = status; + + // 양방향 동기화 + if (status.equals(ReservationStatus.CANCELED) || status.equals(ReservationStatus.REJECTED)) { + mentorSlot.removeReservation(); + } } } diff --git a/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java b/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java index b7c6904b..9ff5bc02 100644 --- a/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java +++ b/back/src/main/java/com/back/domain/mentoring/reservation/repository/ReservationRepository.java @@ -5,4 +5,11 @@ public interface ReservationRepository extends JpaRepository { boolean existsByMentoringId(Long mentoringId); + + /** + * 예약 기록 존재 여부 확인 (모든 상태 포함) + * - 슬롯 삭제 시 데이터 무결성 검증용 + * - 취소/거절된 예약도 히스토리로 보존 + */ + boolean existsByMentorSlotId(Long slotId); } diff --git a/back/src/main/java/com/back/domain/mentoring/slot/controller/MentorSlotController.java b/back/src/main/java/com/back/domain/mentoring/slot/controller/MentorSlotController.java index f3fce346..5eff7ddf 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/controller/MentorSlotController.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/controller/MentorSlotController.java @@ -22,9 +22,23 @@ public class MentorSlotController { private final MentorSlotService mentorSlotService; private final Rq rq; + @GetMapping("/{slotId}") + @Operation(summary = "멘토 슬롯 조회", description = "특정 멘토 슬롯을 조회합니다.") + public RsData getMentorSlot( + @PathVariable Long slotId + ) { + MentorSlotResponse resDto = mentorSlotService.getMentorSlot(slotId); + + return new RsData<>( + "200", + "멘토의 예약 가능 일정을 조회하였습니다.", + resDto + ); + } + @PostMapping @PreAuthorize("hasRole('MENTOR')") - @Operation(summary = "멘토 슬롯 생성") + @Operation(summary = "멘토 슬롯 생성", description = "멘토 슬롯을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") public RsData createMentorSlot( @RequestBody @Valid MentorSlotRequest reqDto ) { @@ -33,13 +47,13 @@ public RsData createMentorSlot( return new RsData<>( "201", - "멘토링 예약 일정을 등록했습니다.", + "멘토의 예약 가능 일정을 등록했습니다.", resDto ); } @PutMapping("/{slotId}") - @Operation(summary = "멘토 슬롯 수정") + @Operation(summary = "멘토 슬롯 수정", description = "멘토 슬롯을 수정합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.") public RsData updateMentorSlot( @PathVariable Long slotId, @RequestBody @Valid MentorSlotRequest reqDto @@ -49,9 +63,22 @@ public RsData updateMentorSlot( return new RsData<>( "200", - "멘토링 예약 일정이 수정되었습니다.", + "멘토의 예약 가능 일정이 수정되었습니다.", resDto ); } + @DeleteMapping("/{slotId}") + @Operation(summary = "멘토 슬롯 삭제", description = "멘토 슬롯을 삭제합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.") + public RsData deleteMentorSlot( + @PathVariable Long slotId + ) { + Member member = rq.getActor(); + mentorSlotService.deleteMentorSlot(slotId, member); + + return new RsData<>( + "200", + "멘토의 예약 가능 일정이 삭제되었습니다." + ); + } } diff --git a/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java b/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java index 37556276..ce5c1e58 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/entity/MentorSlot.java @@ -41,6 +41,11 @@ public MentorSlot(Mentor mentor, LocalDateTime startDateTime, LocalDateTime endD this.status = MentorSlotStatus.AVAILABLE; } + public void updateTime(LocalDateTime startDateTime, LocalDateTime endDateTime) { + this.startDateTime = startDateTime; + this.endDateTime = endDateTime; + } + // ========================= // TODO - 현재 상태 // 1. reservation 필드에는 활성 예약(PENDING, APPROVED)만 세팅 @@ -68,14 +73,28 @@ public void updateStatus() { } } + public void setReservation(Reservation reservation) { + this.reservation = reservation; + updateStatus(); + } + + public void removeReservation() { + this.reservation = null; + updateStatus(); + } + + /** + * 새로운 예약이 가능한지 확인 + * - 예약이 없거나 + * - 예약이 취소/거절된 경우 true + */ public boolean isAvailable() { return reservation == null || reservation.getStatus().equals(ReservationStatus.REJECTED) || reservation.getStatus().equals(ReservationStatus.CANCELED); } - public void update(LocalDateTime startDateTime, LocalDateTime endDateTime) { - this.startDateTime = startDateTime; - this.endDateTime = endDateTime; + public boolean isOwnerBy(Mentor mentor) { + return this.mentor.equals(mentor); } } diff --git a/back/src/main/java/com/back/domain/mentoring/slot/error/MentorSlotErrorCode.java b/back/src/main/java/com/back/domain/mentoring/slot/error/MentorSlotErrorCode.java index 92166927..62d3af2e 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/error/MentorSlotErrorCode.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/error/MentorSlotErrorCode.java @@ -9,17 +9,18 @@ public enum MentorSlotErrorCode implements ErrorCode { // 400 DateTime 체크 - START_TIME_REQUIRED("400-1", "시작 일시와 종료 일시는 필수입니다."), - END_TIME_REQUIRED("400-2", "시작 일시와 종료 일시는 필수입니다."), + START_TIME_REQUIRED("400-1", "시작 일시는 필수입니다."), + END_TIME_REQUIRED("400-2", "종료 일시는 필수입니다."), START_TIME_IN_PAST("400-3", "시작 일시는 현재 이후여야 합니다."), END_TIME_BEFORE_START("400-4", "종료 일시는 시작 일시보다 이후여야 합니다."), - INSUFFICIENT_SLOT_DURATION("400-5", "슬롯은 최소 30분 이상이어야 합니다."), + INSUFFICIENT_SLOT_DURATION("400-5", "슬롯은 최소 20분 이상이어야 합니다."), // 400 Slot 체크 CANNOT_UPDATE_RESERVED_SLOT("400-6", "예약된 슬롯은 수정할 수 없습니다."), + CANNOT_DELETE_RESERVED_SLOT("400-7", "예약된 슬롯은 삭제할 수 없습니다."), // 403 - NOT_OWNER("403-1", "일정의 소유주가 아닙니다."), + NOT_OWNER("403-1", "접근 권한이 없습니다."), // 404 NOT_FOUND_MENTOR_SLOT("404-1", "일정 정보가 없습니다."), diff --git a/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java b/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java index a5885554..25fe2b5b 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/repository/MentorSlotRepository.java @@ -43,4 +43,6 @@ boolean existsOverlappingExcept( @Param("start") LocalDateTime start, @Param("end") LocalDateTime end ); + + long countByMentorId(Long id); } diff --git a/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java b/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java index 7e3e06f9..842bd813 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotService.java @@ -6,6 +6,7 @@ import com.back.domain.mentoring.mentoring.entity.Mentoring; import com.back.domain.mentoring.mentoring.error.MentoringErrorCode; import com.back.domain.mentoring.mentoring.repository.MentoringRepository; +import com.back.domain.mentoring.reservation.repository.ReservationRepository; import com.back.domain.mentoring.slot.dto.request.MentorSlotRequest; import com.back.domain.mentoring.slot.dto.response.MentorSlotResponse; import com.back.domain.mentoring.slot.entity.MentorSlot; @@ -26,16 +27,22 @@ public class MentorSlotService { private final MentorSlotRepository mentorSlotRepository; private final MentorRepository mentorRepository; private final MentoringRepository mentoringRepository; + private final ReservationRepository reservationRepository; + + @Transactional(readOnly = true) + public MentorSlotResponse getMentorSlot(Long slotId) { + MentorSlot mentorSlot = findMentorSlot(slotId); + Mentoring mentoring = findMentoring(mentorSlot.getMentor()); + + return MentorSlotResponse.from(mentorSlot, mentoring); + } @Transactional public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Member member) { Mentor mentor = findMentor(member); Mentoring mentoring = findMentoring(mentor); - // 시간대 유효성 검사 MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); - - // 기존 슬롯과 시간 겹치는지 검사 validateOverlappingSlots(mentor, reqDto.startDateTime(), reqDto.endDateTime()); MentorSlot mentorSlot = MentorSlot.builder() @@ -43,7 +50,6 @@ public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Member memb .startDateTime(reqDto.startDateTime()) .endDateTime(reqDto.endDateTime()) .build(); - mentorSlotRepository.save(mentorSlot); return MentorSlotResponse.from(mentorSlot, mentoring); @@ -55,24 +61,30 @@ public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto Mentoring mentoring = findMentoring(mentor); MentorSlot mentorSlot = findMentorSlot(slotId); - if (!mentorSlot.getMentor().equals(mentor)) { - throw new ServiceException(MentorSlotErrorCode.NOT_OWNER); - } - if (!mentorSlot.isAvailable()) { - throw new ServiceException(MentorSlotErrorCode.CANNOT_UPDATE_RESERVED_SLOT); - } + validateOwner(mentorSlot, mentor); + // 활성화된 예약이 있으면 수정 불가 + validateModification(mentorSlot); - // 시간대 유효성 검사 MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); - - // 기존 슬롯과 시간 겹치는지 검사 validateOverlappingExcept(mentor, mentorSlot, reqDto.startDateTime(), reqDto.endDateTime()); - mentorSlot.update(reqDto.startDateTime(), reqDto.endDateTime()); + mentorSlot.updateTime(reqDto.startDateTime(), reqDto.endDateTime()); return MentorSlotResponse.from(mentorSlot, mentoring); } + @Transactional + public void deleteMentorSlot(Long slotId, Member member) { + Mentor mentor = findMentor(member); + MentorSlot mentorSlot = findMentorSlot(slotId); + + validateOwner(mentorSlot, mentor); + // 예약 기록 존재 여부 검증 (모든 예약 기록 확인) + validateNoReservationHistory(slotId); + + mentorSlotRepository.delete(mentorSlot); + } + // ===== 헬퍼 메서드 ===== @@ -97,15 +109,50 @@ private MentorSlot findMentorSlot(Long slotId) { // ===== 검증 메서드 ===== + private static void validateOwner(MentorSlot mentorSlot, Mentor mentor) { + if (!mentorSlot.isOwnerBy(mentor)) { + throw new ServiceException(MentorSlotErrorCode.NOT_OWNER); + } + } + + /** + * 주어진 시간대가 기존 슬롯과 겹치는지 검증 + * - mentor의 기존 모든 슬롯과 비교 + */ private void validateOverlappingSlots(Mentor mentor, LocalDateTime start, LocalDateTime end) { if (mentorSlotRepository.existsOverlappingSlot(mentor.getId(), start, end)) { throw new ServiceException(MentorSlotErrorCode.OVERLAPPING_SLOT); } } + /** + * 특정 슬롯을 제외하고 시간대가 겹치는지 검증 + * - 대상 슬롯(self)은 제외 + */ private void validateOverlappingExcept(Mentor mentor, MentorSlot mentorSlot, LocalDateTime start, LocalDateTime end) { if (mentorSlotRepository.existsOverlappingExcept(mentor.getId(), mentorSlot.getId(), start, end)) { throw new ServiceException(MentorSlotErrorCode.OVERLAPPING_SLOT); } } + + /** + * 활성화된 예약이 있으면 수정 불가 + * - 예약 취소, 예약 거절 상태는 수정 가능 + */ + private static void validateModification(MentorSlot mentorSlot) { + if (!mentorSlot.isAvailable()) { + throw new ServiceException(MentorSlotErrorCode.CANNOT_UPDATE_RESERVED_SLOT); + } + } + + /** + * 예약 기록이 하나라도 있으면 삭제 불가 + * - 데이터 무결성 보장 + * - 히스토리 보존 + */ + private void validateNoReservationHistory(Long slotId) { + if (reservationRepository.existsByMentorSlotId(slotId)) { + throw new ServiceException(MentorSlotErrorCode.CANNOT_DELETE_RESERVED_SLOT); + } + } } diff --git a/back/src/main/java/com/back/global/exception/ServiceException.java b/back/src/main/java/com/back/global/exception/ServiceException.java index a9edab57..030dbb1d 100644 --- a/back/src/main/java/com/back/global/exception/ServiceException.java +++ b/back/src/main/java/com/back/global/exception/ServiceException.java @@ -1,8 +1,10 @@ package com.back.global.exception; import com.back.global.rsData.RsData; +import lombok.Getter; public class ServiceException extends RuntimeException { + @Getter private final String resultCode; private final String msg; diff --git a/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java b/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java index 359e092c..512b1cc1 100644 --- a/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java @@ -2,8 +2,11 @@ import com.back.domain.member.member.entity.Member; import com.back.domain.member.member.service.AuthTokenService; +import com.back.domain.member.mentee.entity.Mentee; import com.back.domain.member.mentor.entity.Mentor; import com.back.domain.mentoring.mentoring.entity.Mentoring; +import com.back.domain.mentoring.reservation.constant.ReservationStatus; +import com.back.domain.mentoring.reservation.entity.Reservation; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.domain.mentoring.slot.error.MentorSlotErrorCode; import com.back.domain.mentoring.slot.repository.MentorSlotRepository; @@ -27,8 +30,8 @@ import java.util.ArrayList; import java.util.List; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -59,7 +62,7 @@ void setUp() { Member mentorMember = memberFixture.createMentorMember(); mentor = memberFixture.createMentor(mentorMember); - // // JWT 발급 + // JWT 발급 mentorToken = authTokenService.genAccessToken(mentorMember); // Mentoring @@ -70,6 +73,35 @@ void setUp() { mentorSlots = mentoringFixture.createMentorSlots(mentor, baseDateTime, 2, 3); } + // ===== 슬롯 조회 ===== + @Test + @DisplayName("멘토 슬롯 조회 성공") + void getMentorSlotSuccess() throws Exception { + MentorSlot mentorSlot = mentorSlots.getFirst(); + + ResultActions resultActions = mvc.perform( + get(MENTOR_SLOT_URL + "/" + mentorSlot.getId()) + .cookie(new Cookie(TOKEN, mentorToken)) + ) + .andDo(print()); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + resultActions + .andExpect(status().isOk()) + .andExpect(handler().handlerType(MentorSlotController.class)) + .andExpect(handler().methodName("getMentorSlot")) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정을 조회하였습니다.")) + .andExpect(jsonPath("$.data.mentorSlotId").value(mentorSlot.getId())) + .andExpect(jsonPath("$.data.mentorId").value(mentorSlot.getMentor().getId())) + .andExpect(jsonPath("$.data.mentoringId").value(mentoring.getId())) + .andExpect(jsonPath("$.data.mentoringTitle").value(mentoring.getTitle())) + .andExpect(jsonPath("$.data.startDateTime").value(mentorSlot.getStartDateTime().format(formatter))) + .andExpect(jsonPath("$.data.endDateTime").value(mentorSlot.getEndDateTime().format(formatter))) + .andExpect(jsonPath("$.data.mentorSlotStatus").value(mentorSlot.getStatus().name())); + } + // ===== 슬롯 생성 ===== @Test @@ -81,7 +113,7 @@ void createMentorSlotSuccess() throws Exception { ResultActions resultActions = performCreateMentorSlot(mentor.getId(), mentorToken, startDateTime, endDateTime) .andExpect(status().isCreated()) .andExpect(jsonPath("$.resultCode").value("201")) - .andExpect(jsonPath("$.msg").value("멘토링 예약 일정을 등록했습니다.")); + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정을 등록했습니다.")); MentorSlot mentorSlot = mentorSlotRepository.findTopByOrderByIdDesc() .orElseThrow(() -> new ServiceException(MentorSlotErrorCode.NOT_FOUND_MENTOR_SLOT)); @@ -130,35 +162,50 @@ void createMentorSlotFailOverlappingSlots() throws Exception { // ===== 슬롯 수정 ===== @Test - @DisplayName("멘토 슬롯 수정 성공") + @DisplayName("멘토 슬롯 수정 성공 - 예약이 없는 경우") void updateMentorSlotSuccess() throws Exception { MentorSlot mentorSlot = mentorSlots.getFirst(); LocalDateTime updateEndDate = mentorSlot.getEndDateTime().minusMinutes(10); - String req = """ - { - "mentorId": %d, - "startDateTime": "%s", - "endDateTime": "%s" - } - """.formatted(mentor.getId(), mentorSlot.getStartDateTime(), updateEndDate); + ResultActions resultActions = performUpdateMentorSlot(mentor.getId(), mentorToken, mentorSlot, updateEndDate); - ResultActions resultActions = mvc.perform( - put(MENTOR_SLOT_URL + "/" + mentorSlot.getId()) - .cookie(new Cookie(TOKEN, mentorToken)) - .contentType(MediaType.APPLICATION_JSON) - .content(req) - ) - .andDo(print()); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + String expectedEndDate = updateEndDate.format(formatter); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정이 수정되었습니다.")) + .andExpect(jsonPath("$.data.mentorSlotId").value(mentorSlot.getId())) + .andExpect(jsonPath("$.data.mentorId").value(mentorSlot.getMentor().getId())) + .andExpect(jsonPath("$.data.mentoringId").value(mentoring.getId())) + .andExpect(jsonPath("$.data.mentoringTitle").value(mentoring.getTitle())) + .andExpect(jsonPath("$.data.endDateTime").value(expectedEndDate)) + .andExpect(jsonPath("$.data.mentorSlotStatus").value("AVAILABLE")); + } + + @Test + @DisplayName("멘토 슬롯 수정 성공 - 비활성화된 예약이 있는 경우") + void updateMentorSlotSuccessReserved() throws Exception { + MentorSlot mentorSlot = mentorSlots.getFirst(); + + // 예약 생성 및 취소 + Member menteeMember = memberFixture.createMenteeMember(); + Mentee mentee = memberFixture.createMentee(menteeMember); + Reservation reservation = mentoringFixture.createReservation(mentoring, mentee, mentorSlot); + reservation.updateStatus(ReservationStatus.CANCELED); + + // 수정 API + LocalDateTime updateEndDate = mentorSlot.getEndDateTime().minusMinutes(10); + ResultActions resultActions = performUpdateMentorSlot(mentor.getId(), mentorToken, mentorSlot, updateEndDate); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); String expectedEndDate = updateEndDate.format(formatter); resultActions - .andExpect(handler().handlerType(MentorSlotController.class)) - .andExpect(handler().methodName("updateMentorSlot")) + .andExpect(status().isOk()) .andExpect(jsonPath("$.resultCode").value("200")) - .andExpect(jsonPath("$.msg").value("멘토링 예약 일정이 수정되었습니다.")) + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정이 수정되었습니다.")) .andExpect(jsonPath("$.data.mentorSlotId").value(mentorSlot.getId())) .andExpect(jsonPath("$.data.mentorId").value(mentorSlot.getMentor().getId())) .andExpect(jsonPath("$.data.mentoringId").value(mentoring.getId())) @@ -167,6 +214,105 @@ void updateMentorSlotSuccess() throws Exception { .andExpect(jsonPath("$.data.mentorSlotStatus").value("AVAILABLE")); } + @Test + @DisplayName("멘토 슬롯 수정 실패 - 작성자가 아닌 경우") + void updateMentorSlotFailNotOwner() throws Exception { + Member mentorMember2 = memberFixture.createMentorMember(); + Mentor mentor2 = memberFixture.createMentor(mentorMember2); + mentoringFixture.createMentoring(mentor2); + String token = authTokenService.genAccessToken(mentorMember2); + + MentorSlot mentorSlot = mentorSlots.getFirst(); + LocalDateTime updateEndDate = mentorSlots.get(1).getEndDateTime(); + + performUpdateMentorSlot(mentor2.getId(), token, mentorSlot, updateEndDate) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.resultCode").value("403-1")) + .andExpect(jsonPath("$.msg").value("접근 권한이 없습니다.")); + } + + @Test + @DisplayName("멘토 슬롯 수정 실패 - 기존 슬롯과 겹치는지 검사") + void updateMentorSlotFailOverlapping() throws Exception { + MentorSlot mentorSlot = mentorSlots.getFirst(); + LocalDateTime updateEndDate = mentorSlots.get(1).getEndDateTime(); + + performUpdateMentorSlot(mentor.getId(), mentorToken, mentorSlot, updateEndDate) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.resultCode").value("409-1")) + .andExpect(jsonPath("$.msg").value("선택한 시간은 이미 예약된 시간대입니다.")); + } + + @Test + @DisplayName("멘토 슬롯 수정 실패 - 활성화된 예약이 있는 경우") + void updateMentorSlotFailReserved() throws Exception { + MentorSlot mentorSlot = mentorSlots.getFirst(); + + // 예약 생성 + Member menteeMember = memberFixture.createMenteeMember(); + Mentee mentee = memberFixture.createMentee(menteeMember); + mentoringFixture.createReservation(mentoring, mentee, mentorSlot); + + LocalDateTime updateEndDate = mentorSlot.getEndDateTime().minusMinutes(10); + + performUpdateMentorSlot(mentor.getId(), mentorToken, mentorSlot, updateEndDate) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.resultCode").value("400-6")) + .andExpect(jsonPath("$.msg").value("예약된 슬롯은 수정할 수 없습니다.")); + } + + + // ===== delete ===== + + @Test + @DisplayName("멘토 슬롯 삭제 성공") + void deleteMentorSlotSuccess() throws Exception { + long beforeCnt = mentorSlotRepository.countByMentorId(mentor.getId()); + MentorSlot mentorSlot = mentorSlots.getFirst(); + + ResultActions resultActions = performDeleteMentorSlot(mentorSlot, mentorToken); + + long afterCnt = mentorSlotRepository.countByMentorId(mentor.getId()); + + resultActions + .andExpect(handler().handlerType(MentorSlotController.class)) + .andExpect(handler().methodName("deleteMentorSlot")) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정이 삭제되었습니다.")); + + assertThat(afterCnt).isEqualTo(beforeCnt - 1); + } + + @Test + @DisplayName("멘토 슬롯 삭제 실패 - 작성자가 아닌 경우") + void deleteMentorSlotFailNotOwner() throws Exception { + Member mentorMember2 = memberFixture.createMentorMember(); + memberFixture.createMentor(mentorMember2); + String token = authTokenService.genAccessToken(mentorMember2); + + performDeleteMentorSlot(mentorSlots.getFirst(), token) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.resultCode").value("403-1")) + .andExpect(jsonPath("$.msg").value("접근 권한이 없습니다.")); + } + + @Test + @DisplayName("멘토 슬롯 삭제 실패 - 예약이 있는 경우") + void deleteMentorSlotFailReserved() throws Exception { + MentorSlot mentorSlot = mentorSlots.getFirst(); + + // 예약 생성 및 취소 + Member menteeMember = memberFixture.createMenteeMember(); + Mentee mentee = memberFixture.createMentee(menteeMember); + Reservation reservation = mentoringFixture.createReservation(mentoring, mentee, mentorSlot); + reservation.updateStatus(ReservationStatus.CANCELED); + + performDeleteMentorSlot(mentorSlot, mentorToken) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.resultCode").value("400-7")) + .andExpect(jsonPath("$.msg").value("예약된 슬롯은 삭제할 수 없습니다.")); + } + // ===== perform ===== @@ -189,4 +335,34 @@ private ResultActions performCreateMentorSlot(Long mentorId, String token, Strin .andExpect(handler().handlerType(MentorSlotController.class)) .andExpect(handler().methodName("createMentorSlot")); } + + private ResultActions performUpdateMentorSlot(Long mentorId, String token, MentorSlot mentorSlot, LocalDateTime updateEndDate) throws Exception { + String req = """ + { + "mentorId": %d, + "startDateTime": "%s", + "endDateTime": "%s" + } + """.formatted(mentorId, mentorSlot.getStartDateTime(), updateEndDate); + + return mvc.perform( + put(MENTOR_SLOT_URL + "/" + mentorSlot.getId()) + .cookie(new Cookie(TOKEN, token)) + .contentType(MediaType.APPLICATION_JSON) + .content(req) + ) + .andDo(print()) + .andExpect(handler().handlerType(MentorSlotController.class)) + .andExpect(handler().methodName("updateMentorSlot")); + } + + private ResultActions performDeleteMentorSlot(MentorSlot mentorSlot, String token) throws Exception { + return mvc.perform( + delete(MENTOR_SLOT_URL + "/" + mentorSlot.getId()) + .cookie(new Cookie(TOKEN, token)) + ) + .andDo(print()) + .andExpect(handler().handlerType(MentorSlotController.class)) + .andExpect(handler().methodName("deleteMentorSlot")); + } } \ No newline at end of file diff --git a/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotValidatorTest.java b/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotValidatorTest.java new file mode 100644 index 00000000..0984cb7f --- /dev/null +++ b/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotValidatorTest.java @@ -0,0 +1,149 @@ +package com.back.domain.mentoring.slot.service; + +import com.back.domain.mentoring.slot.error.MentorSlotErrorCode; +import com.back.global.exception.ServiceException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +class MentorSlotValidatorTest { + + @Test + @DisplayName("시작 일시, 종료 일시 기입 시 정상 처리") + void validateNotNull_success() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = LocalDateTime.now().plusHours(2); + + // when & then + assertDoesNotThrow(() -> MentorSlotValidator.validateNotNull(start, end)); + } + + @Test + @DisplayName("시작 일시 누락 시 예외 발생") + void validateNotNull_fail_startNull() { + // given + LocalDateTime end = LocalDateTime.now().plusHours(1); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, + () -> MentorSlotValidator.validateNotNull(null, end)); + + assertEquals(MentorSlotErrorCode.START_TIME_REQUIRED.getCode(), exception.getResultCode()); + } + + @Test + @DisplayName("종료 일시 누락 시 예외 발생") + void validateNotNull_fail_endNull() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, + () -> MentorSlotValidator.validateNotNull(start, null)); + + assertEquals(MentorSlotErrorCode.END_TIME_REQUIRED.getCode(), exception.getResultCode()); + } + + @Test + @DisplayName("현재 이후의 시작 일시, 종료 일시 기입 시 정상 처리") + void validateTimeRange_success() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = start.plusHours(1); + + // when & then + assertDoesNotThrow(() -> MentorSlotValidator.validateTimeRange(start, end)); + } + + @Test + @DisplayName("시작 일시가 현재보다 이전이면 예외 발생") + void validateTimeRange_fail_startTimeInPast() { + // given + LocalDateTime start = LocalDateTime.now().minusHours(1); + LocalDateTime end = LocalDateTime.now().plusHours(1); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, + () -> MentorSlotValidator.validateTimeRange(start, end)); + + assertEquals(MentorSlotErrorCode.START_TIME_IN_PAST.getCode(), exception.getResultCode()); + } + + @Test + @DisplayName("종료 일시가 시작 일시보다 이전이면 예외 발생") + void validateTimeRange_fail_endTimeBeforeStart() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(2); + LocalDateTime end = LocalDateTime.now().plusHours(1); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, + () -> MentorSlotValidator.validateTimeRange(start, end)); + + assertEquals(MentorSlotErrorCode.END_TIME_BEFORE_START.getCode(), exception.getResultCode()); + } + + @Test + @DisplayName("20분 이상의 슬롯 기간 입력 시 정상 처리") + void validateMinimumDuration_success() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = start.plusHours(1); + + // when & then + assertDoesNotThrow(() -> MentorSlotValidator.validateMinimumDuration(start, end)); + } + + @Test + @DisplayName("정확히 20분인 경우 정상 처리") + void validateMinimumDuration_success_exactly20Minutes() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = start.plusMinutes(20); + + // when & then + assertDoesNotThrow(() -> MentorSlotValidator.validateMinimumDuration(start, end)); + } + + @Test + @DisplayName("슬롯 기간이 최소 시간(20분)보다 짧으면 예외 발생") + void validateMinimumDuration_fail_insufficientSlotDuration() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = start.plusMinutes(19); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, + () -> MentorSlotValidator.validateMinimumDuration(start, end)); + + assertEquals(MentorSlotErrorCode.INSUFFICIENT_SLOT_DURATION.getCode(), exception.getResultCode()); + } + + @Test + @DisplayName("모든 검증을 통과하는 정상 케이스") + void validateTimeSlot_success() { + // given + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = start.plusMinutes(30); + + // when & then + assertDoesNotThrow(() -> MentorSlotValidator.validateTimeSlot(start, end)); + } + + @Test + @DisplayName("validateTimeSlot 메소드에서 null 체크 예외 발생") + void validateTimeSlot_fail_nullCheck() { + // given + LocalDateTime end = LocalDateTime.now().plusHours(1); + + // when & then + ServiceException exception = assertThrows(ServiceException.class, + () -> MentorSlotValidator.validateTimeSlot(null, end)); + + assertEquals(MentorSlotErrorCode.START_TIME_REQUIRED.getCode(), exception.getResultCode()); + } +} \ No newline at end of file