From 17ab3de10039c0a6ae561d97fd8348f25dee9eb3 Mon Sep 17 00:00:00 2001 From: sso0om Date: Wed, 24 Sep 2025 16:55:32 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Docs:=20=EB=A9=98=ED=86=A0=EB=A7=81=20api?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=83=81=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mentoring/controller/MentoringController.java | 10 +++++----- .../slot/controller/MentorSlotController.java | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) 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/slot/controller/MentorSlotController.java b/back/src/main/java/com/back/domain/mentoring/slot/controller/MentorSlotController.java index f3fce346..76f4e398 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 @@ -24,7 +24,7 @@ public class MentorSlotController { @PostMapping @PreAuthorize("hasRole('MENTOR')") - @Operation(summary = "멘토 슬롯 생성") + @Operation(summary = "멘토 슬롯 생성", description = "멘토 슬롯을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") public RsData createMentorSlot( @RequestBody @Valid MentorSlotRequest reqDto ) { @@ -39,7 +39,7 @@ public RsData createMentorSlot( } @PutMapping("/{slotId}") - @Operation(summary = "멘토 슬롯 수정") + @Operation(summary = "멘토 슬롯 수정", description = "멘토 슬롯을 수정합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.") public RsData updateMentorSlot( @PathVariable Long slotId, @RequestBody @Valid MentorSlotRequest reqDto From d096b1404bdecf27cfd55fb58cb950fcdddc768c Mon Sep 17 00:00:00 2001 From: sso0om Date: Wed, 24 Sep 2025 17:41:44 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EC=8A=AC=EB=A1=AF=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=A4=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MentorSlotControllerTest.java | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) 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..151ca0dd 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 @@ -135,28 +135,13 @@ 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 = mvc.perform( - put(MENTOR_SLOT_URL + "/" + mentorSlot.getId()) - .cookie(new Cookie(TOKEN, mentorToken)) - .contentType(MediaType.APPLICATION_JSON) - .content(req) - ) - .andDo(print()); + 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("$.data.mentorSlotId").value(mentorSlot.getId())) @@ -167,6 +152,18 @@ void updateMentorSlotSuccess() throws Exception { .andExpect(jsonPath("$.data.mentorSlotStatus").value("AVAILABLE")); } + @Test + @DisplayName("멘토 슬롯 수정 실패 - 기존 슬롯과 겹치는지 검사") + void updateMentorSlotFail() 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("선택한 시간은 이미 예약된 시간대입니다.")); + } + // ===== perform ===== @@ -189,4 +186,25 @@ 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")); + } + } \ No newline at end of file From ab6376f064f4e69f4b6bb4aa655ab2f08160a985 Mon Sep 17 00:00:00 2001 From: sso0om Date: Wed, 24 Sep 2025 21:41:02 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Feat:=20=EB=A9=98=ED=86=A0=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=82=AD=EC=A0=9C,=20=EC=8A=AC=EB=A1=AF-=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/entity/Reservation.java | 12 ++ .../repository/ReservationRepository.java | 7 + .../slot/controller/MentorSlotController.java | 14 ++ .../mentoring/slot/entity/MentorSlot.java | 25 +++- .../slot/error/MentorSlotErrorCode.java | 3 +- .../slot/repository/MentorSlotRepository.java | 2 + .../slot/service/MentorSlotService.java | 67 +++++++-- .../controller/MentorSlotControllerTest.java | 137 +++++++++++++++++- 8 files changed, 245 insertions(+), 22 deletions(-) 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 76f4e398..57b91f67 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 @@ -54,4 +54,18 @@ public RsData updateMentorSlot( ); } + @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..dd15db3a 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 @@ -17,9 +17,10 @@ public enum MentorSlotErrorCode implements ErrorCode { // 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..d6177cbe 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,14 @@ public class MentorSlotService { private final MentorSlotRepository mentorSlotRepository; private final MentorRepository mentorRepository; private final MentoringRepository mentoringRepository; + private final ReservationRepository reservationRepository; @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 +42,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 +53,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 +101,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/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java b/back/src/test/java/com/back/domain/mentoring/slot/controller/MentorSlotControllerTest.java index 151ca0dd..2a36a1cc 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.*; @@ -130,7 +133,7 @@ void createMentorSlotFailOverlappingSlots() throws Exception { // ===== 슬롯 수정 ===== @Test - @DisplayName("멘토 슬롯 수정 성공") + @DisplayName("멘토 슬롯 수정 성공 - 예약이 없는 경우") void updateMentorSlotSuccess() throws Exception { MentorSlot mentorSlot = mentorSlots.getFirst(); LocalDateTime updateEndDate = mentorSlot.getEndDateTime().minusMinutes(10); @@ -152,9 +155,56 @@ void updateMentorSlotSuccess() throws Exception { .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(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 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 updateMentorSlotFail() throws Exception { + void updateMentorSlotFailOverlapping() throws Exception { MentorSlot mentorSlot = mentorSlots.getFirst(); LocalDateTime updateEndDate = mentorSlots.get(1).getEndDateTime(); @@ -164,6 +214,76 @@ void updateMentorSlotFail() throws Exception { .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 ===== @@ -207,4 +327,13 @@ private ResultActions performUpdateMentorSlot(Long mentorId, String token, Mento .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 From 0b2cbe7219f92b30f37401bb294346284af03eca Mon Sep 17 00:00:00 2001 From: sso0om Date: Wed, 24 Sep 2025 22:46:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Feat:=20=EB=A9=98=ED=86=A0=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slot/controller/MentorSlotController.java | 21 +++++++++-- .../slot/service/MentorSlotService.java | 8 ++++ .../controller/MentorSlotControllerTest.java | 37 +++++++++++++++++-- 3 files changed, 58 insertions(+), 8 deletions(-) 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 57b91f67..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,6 +22,20 @@ 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 = "멘토 슬롯 생성", description = "멘토 슬롯을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") @@ -33,7 +47,7 @@ public RsData createMentorSlot( return new RsData<>( "201", - "멘토링 예약 일정을 등록했습니다.", + "멘토의 예약 가능 일정을 등록했습니다.", resDto ); } @@ -49,7 +63,7 @@ public RsData updateMentorSlot( return new RsData<>( "200", - "멘토링 예약 일정이 수정되었습니다.", + "멘토의 예약 가능 일정이 수정되었습니다.", resDto ); } @@ -64,8 +78,7 @@ public RsData deleteMentorSlot( return new RsData<>( "200", - "멘토링 예약 일정이 삭제되었습니다." + "멘토의 예약 가능 일정이 삭제되었습니다." ); } - } 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 d6177cbe..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 @@ -29,6 +29,14 @@ public class MentorSlotService { 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); 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 2a36a1cc..1b376437 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 @@ -73,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 @@ -84,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)); @@ -146,7 +175,7 @@ void updateMentorSlotSuccess() throws Exception { resultActions .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())) @@ -176,7 +205,7 @@ void updateMentorSlotSuccessReserved() throws Exception { resultActions .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())) @@ -249,7 +278,7 @@ void deleteMentorSlotSuccess() throws Exception { .andExpect(handler().handlerType(MentorSlotController.class)) .andExpect(handler().methodName("deleteMentorSlot")) .andExpect(jsonPath("$.resultCode").value("200")) - .andExpect(jsonPath("$.msg").value("멘토링 예약 일정이 삭제되었습니다.")); + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정이 삭제되었습니다.")); assertThat(afterCnt).isEqualTo(beforeCnt - 1); } From 528402b2c0ea555b7b22408b1b02d5bd8f4bc2fc Mon Sep 17 00:00:00 2001 From: sso0om Date: Thu, 25 Sep 2025 12:15:25 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20MentorSlotValidatorTest=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slot/error/MentorSlotErrorCode.java | 6 +- .../global/exception/ServiceException.java | 2 + .../controller/MentorSlotControllerTest.java | 2 +- .../slot/service/MentorSlotValidatorTest.java | 149 ++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotValidatorTest.java 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 dd15db3a..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,11 +9,11 @@ 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", "예약된 슬롯은 수정할 수 없습니다."), 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 1b376437..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 @@ -62,7 +62,7 @@ void setUp() { Member mentorMember = memberFixture.createMentorMember(); mentor = memberFixture.createMentor(mentorMember); - // // JWT 발급 + // JWT 발급 mentorToken = authTokenService.genAccessToken(mentorMember); // Mentoring 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