diff --git a/back/src/main/java/com/back/domain/member/member/entity/Member.java b/back/src/main/java/com/back/domain/member/member/entity/Member.java index 356925a5..781d9255 100644 --- a/back/src/main/java/com/back/domain/member/member/entity/Member.java +++ b/back/src/main/java/com/back/domain/member/member/entity/Member.java @@ -1,17 +1,19 @@ package com.back.domain.member.member.entity; import com.back.global.jpa.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + @Entity @Getter @NoArgsConstructor public class Member extends BaseEntity { + @Column(unique = true, nullable = false, length = 36) + private String publicId; + @Column(unique = true, nullable = false) private String email; @@ -51,4 +53,11 @@ public Member(Long id, String email, String name, String nickname, Role role) { this.nickname = nickname; this.role = role; } + + @PrePersist + public void generatePublicId() { + if (this.publicId == null) { + this.publicId = UUID.randomUUID().toString(); + } + } } 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 f3247193..111b6679 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 @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/mentoring") +@RequestMapping("/mentorings") @RequiredArgsConstructor @Tag(name = "MentoringController", description = "멘토링 API") public class MentoringController { 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 5eff7ddf..22631694 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 @@ -1,7 +1,9 @@ package com.back.domain.mentoring.slot.controller; import com.back.domain.member.member.entity.Member; +import com.back.domain.mentoring.slot.dto.request.MentorSlotRepetitionRequest; import com.back.domain.mentoring.slot.dto.request.MentorSlotRequest; +import com.back.domain.mentoring.slot.dto.response.MentorSlotDto; import com.back.domain.mentoring.slot.dto.response.MentorSlotResponse; import com.back.domain.mentoring.slot.service.MentorSlotService; import com.back.global.rq.Rq; @@ -10,11 +12,16 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + @RestController -@RequestMapping("/mentor-slot") +@RequestMapping("/mentor-slots") @RequiredArgsConstructor @Tag(name = "MentorSlotController", description = "멘토 슬롯(멘토의 예약 가능 일정) API") public class MentorSlotController { @@ -22,6 +29,46 @@ public class MentorSlotController { private final MentorSlotService mentorSlotService; private final Rq rq; + @GetMapping + @PreAuthorize("hasRole('MENTOR')") + @Operation(summary = "멘토의 모든 슬롯 목록 조회", description = "멘토가 본인의 모든 슬롯(예약된 슬롯 포함) 목록을 조회합니다.") + public RsData> getMyMentorSlots( + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate + ) { + Member member = rq.getActor(); + + LocalDateTime startDateTime = startDate.atStartOfDay(); + LocalDateTime endDateTime = endDate.atStartOfDay(); + + List resDtoList = mentorSlotService.getMyMentorSlots(member, startDateTime, endDateTime); + + return new RsData<>( + "200", + "나의 모든 일정 목록을 조회하였습니다.", + resDtoList + ); + } + + @GetMapping("/available/{mentorId}") + @Operation(summary = "멘토의 예약 가능한 슬롯 목록 조회", description = "멘티가 특정 멘토의 예약 가능한 슬롯 목록을 조회합니다.") + public RsData> getAvailableMentorSlots( + @PathVariable Long mentorId, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate + ) { + LocalDateTime startDateTime = startDate.atStartOfDay(); + LocalDateTime endDateTime = endDate.atStartOfDay(); + + List resDtoList = mentorSlotService.getAvailableMentorSlots(mentorId, startDateTime, endDateTime); + + return new RsData<>( + "200", + "멘토의 예약 가능 일정 목록을 조회하였습니다.", + resDtoList + ); + } + @GetMapping("/{slotId}") @Operation(summary = "멘토 슬롯 조회", description = "특정 멘토 슬롯을 조회합니다.") public RsData getMentorSlot( @@ -52,6 +99,21 @@ public RsData createMentorSlot( ); } + @PostMapping("/repetition") + @PreAuthorize("hasRole('MENTOR')") + @Operation(summary = "반복 슬롯 생성", description = "멘토 슬롯을 반복 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") + public RsData createMentorSlotRepetition( + @RequestBody @Valid MentorSlotRepetitionRequest reqDto + ) { + Member member = rq.getActor(); + mentorSlotService.createMentorSlotRepetition(reqDto, member); + + return new RsData<>( + "201", + "반복 일정을 등록했습니다." + ); + } + @PutMapping("/{slotId}") @Operation(summary = "멘토 슬롯 수정", description = "멘토 슬롯을 수정합니다. 멘토 슬롯 작성자만 접근할 수 있습니다.") public RsData updateMentorSlot( diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/MentorSlotDto.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/MentorSlotDto.java deleted file mode 100644 index 6eeb673a..00000000 --- a/back/src/main/java/com/back/domain/mentoring/slot/dto/MentorSlotDto.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.back.domain.mentoring.slot.dto; - -import com.back.domain.mentoring.slot.constant.MentorSlotStatus; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.time.LocalDateTime; - -public record MentorSlotDto( - @Schema(description = "멘토링 슬롯 ID") - Long mentorSlotId, - @Schema(description = "시작 일시") - LocalDateTime startDateTime, - @Schema(description = "종료 일시") - LocalDateTime endDateTime, - @Schema(description = "멘토 슬롯 상태") - MentorSlotStatus mentorSlotStatus -) { -} diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java new file mode 100644 index 00000000..03ac7b8e --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/request/MentorSlotRepetitionRequest.java @@ -0,0 +1,36 @@ +package com.back.domain.mentoring.slot.dto.request; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public record MentorSlotRepetitionRequest( + @Schema(description = "반복 시작일") + @NotNull + LocalDate repeatStartDate, + + @Schema(description = "반복 종료일") + @NotNull + LocalDate repeatEndDate, + + @Schema(description = "반복 요일") + @NotEmpty + List daysOfWeek, + + @Schema(description = "시작 시간") + @NotNull + @JsonFormat(pattern = "HH:mm:ss") + LocalTime startTime, + + @Schema(description = "종료 시간") + @NotNull + @JsonFormat(pattern = "HH:mm:ss") + LocalTime endTime +){ +} diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java new file mode 100644 index 00000000..f6bdc831 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotDto.java @@ -0,0 +1,30 @@ +package com.back.domain.mentoring.slot.dto.response; + +import com.back.domain.mentoring.slot.constant.MentorSlotStatus; +import com.back.domain.mentoring.slot.entity.MentorSlot; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +public record MentorSlotDto( + @Schema(description = "멘토 슬롯 ID") + Long mentorSlotId, + @Schema(description = "멘토 ID") + Long mentorId, + @Schema(description = "시작 일시") + LocalDateTime startDateTime, + @Schema(description = "종료 일시") + LocalDateTime endDateTime, + @Schema(description = "멘토 슬롯 상태") + MentorSlotStatus mentorSlotStatus +) { + public static MentorSlotDto from(MentorSlot mentorSlot) { + return new MentorSlotDto( + mentorSlot.getId(), + mentorSlot.getMentor().getId(), + mentorSlot.getStartDateTime(), + mentorSlot.getEndDateTime(), + mentorSlot.getStatus() + ); + } +} diff --git a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java index e951764e..8106144a 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/dto/response/MentorSlotResponse.java @@ -8,7 +8,7 @@ import java.time.LocalDateTime; public record MentorSlotResponse( - @Schema(description = "멘토링 슬롯 ID") + @Schema(description = "멘토 슬롯 ID") Long mentorSlotId, @Schema(description = "멘토 ID") 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 25fe2b5b..83b5cc17 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 @@ -6,13 +6,45 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MentorSlotRepository extends JpaRepository { Optional findTopByOrderByIdDesc(); + boolean existsByMentorId(Long mentorId); + long countByMentorId(Long mentorId); void deleteAllByMentorId(Long mentorId); + @Query(""" + SELECT ms + FROM MentorSlot ms + WHERE ms.mentor.id = :mentorId + AND ms.startDateTime < :end + AND ms.endDateTime >= :start + ORDER BY ms.startDateTime ASC + """) + List findMySlots( + @Param("mentorId") Long mentorId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + + @Query(""" + SELECT ms + FROM MentorSlot ms + WHERE ms.mentor.id = :mentorId + AND ms.status = 'AVAILABLE' + AND ms.startDateTime < :end + AND ms.endDateTime >= :start + ORDER BY ms.startDateTime ASC + """) + List findAvailableSlots( + @Param("mentorId") Long mentorId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + // TODO: 현재는 시간 겹침만 체크, 추후 1:N 구조 시 활성 예약 기준으로 변경 @Query(""" SELECT CASE WHEN COUNT(ms) > 0 @@ -43,6 +75,4 @@ 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/MentorSlotValidator.java b/back/src/main/java/com/back/domain/mentoring/slot/service/DateTimeValidator.java similarity index 74% rename from back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotValidator.java rename to back/src/main/java/com/back/domain/mentoring/slot/service/DateTimeValidator.java index 048dc6d1..cbeb4ead 100644 --- a/back/src/main/java/com/back/domain/mentoring/slot/service/MentorSlotValidator.java +++ b/back/src/main/java/com/back/domain/mentoring/slot/service/DateTimeValidator.java @@ -6,7 +6,7 @@ import java.time.Duration; import java.time.LocalDateTime; -public class MentorSlotValidator { +public class DateTimeValidator { private static final int MIN_SLOT_DURATION = 20; @@ -19,10 +19,13 @@ public static void validateNotNull(LocalDateTime start, LocalDateTime end) { } } - public static void validateTimeRange(LocalDateTime start, LocalDateTime end) { + public static void validateEndTimeAfterStart(LocalDateTime start, LocalDateTime end) { if (!end.isAfter(start)) { throw new ServiceException(MentorSlotErrorCode.END_TIME_BEFORE_START); } + } + + public static void validateStartTimeNotInPast(LocalDateTime start) { if (start.isBefore(LocalDateTime.now())) { throw new ServiceException(MentorSlotErrorCode.START_TIME_IN_PAST); } @@ -35,9 +38,16 @@ public static void validateMinimumDuration(LocalDateTime start, LocalDateTime en } } + public static void validateTime(LocalDateTime start, LocalDateTime end) { + validateNotNull(start, end); + validateEndTimeAfterStart(start, end); + } + public static void validateTimeSlot(LocalDateTime start, LocalDateTime end) { validateNotNull(start, end); - validateTimeRange(start, end); + validateEndTimeAfterStart(start, end); + + validateStartTimeNotInPast(start); validateMinimumDuration(start, end); } } 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 842bd813..e106e2a3 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 @@ -7,7 +7,9 @@ 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.MentorSlotRepetitionRequest; import com.back.domain.mentoring.slot.dto.request.MentorSlotRequest; +import com.back.domain.mentoring.slot.dto.response.MentorSlotDto; import com.back.domain.mentoring.slot.dto.response.MentorSlotResponse; import com.back.domain.mentoring.slot.entity.MentorSlot; import com.back.domain.mentoring.slot.error.MentorSlotErrorCode; @@ -17,7 +19,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; import java.util.List; @Service @@ -29,6 +35,31 @@ public class MentorSlotService { private final MentoringRepository mentoringRepository; private final ReservationRepository reservationRepository; + @Transactional(readOnly = true) + public List getMyMentorSlots(Member member, LocalDateTime startDate, LocalDateTime endDate) { + Mentor mentor = findMentorByMember(member); + + DateTimeValidator.validateTime(startDate, endDate); + + List availableSlots = mentorSlotRepository.findMySlots(mentor.getId(), startDate, endDate); + + return availableSlots.stream() + .map(MentorSlotDto::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getAvailableMentorSlots(Long mentorId, LocalDateTime startDate, LocalDateTime endDate) { + validateMentorExists(mentorId); + DateTimeValidator.validateTime(startDate, endDate); + + List availableSlots = mentorSlotRepository.findAvailableSlots(mentorId, startDate, endDate); + + return availableSlots.stream() + .map(MentorSlotDto::from) + .toList(); + } + @Transactional(readOnly = true) public MentorSlotResponse getMentorSlot(Long slotId) { MentorSlot mentorSlot = findMentorSlot(slotId); @@ -39,10 +70,10 @@ public MentorSlotResponse getMentorSlot(Long slotId) { @Transactional public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Member member) { - Mentor mentor = findMentor(member); + Mentor mentor = findMentorByMember(member); Mentoring mentoring = findMentoring(mentor); - MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); + DateTimeValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); validateOverlappingSlots(mentor, reqDto.startDateTime(), reqDto.endDateTime()); MentorSlot mentorSlot = MentorSlot.builder() @@ -55,9 +86,22 @@ public MentorSlotResponse createMentorSlot(MentorSlotRequest reqDto, Member memb return MentorSlotResponse.from(mentorSlot, mentoring); } + @Transactional + public void createMentorSlotRepetition(MentorSlotRepetitionRequest reqDto, Member member) { + Mentor mentor = findMentorByMember(member); + + List mentorSlots = new ArrayList<>(); + + // 지정한 요일별로 슬롯 목록 생성 + for(DayOfWeek targetDayOfWeek : reqDto.daysOfWeek()) { + mentorSlots.addAll(generateSlotsForDayOfWeek(reqDto, targetDayOfWeek, mentor)); + } + mentorSlotRepository.saveAll(mentorSlots); + } + @Transactional public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto, Member member) { - Mentor mentor = findMentor(member); + Mentor mentor = findMentorByMember(member); Mentoring mentoring = findMentoring(mentor); MentorSlot mentorSlot = findMentorSlot(slotId); @@ -65,7 +109,7 @@ public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto // 활성화된 예약이 있으면 수정 불가 validateModification(mentorSlot); - MentorSlotValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); + DateTimeValidator.validateTimeSlot(reqDto.startDateTime(), reqDto.endDateTime()); validateOverlappingExcept(mentor, mentorSlot, reqDto.startDateTime(), reqDto.endDateTime()); mentorSlot.updateTime(reqDto.startDateTime(), reqDto.endDateTime()); @@ -75,7 +119,7 @@ public MentorSlotResponse updateMentorSlot(Long slotId, MentorSlotRequest reqDto @Transactional public void deleteMentorSlot(Long slotId, Member member) { - Mentor mentor = findMentor(member); + Mentor mentor = findMentorByMember(member); MentorSlot mentorSlot = findMentorSlot(slotId); validateOwner(mentorSlot, mentor); @@ -86,9 +130,45 @@ public void deleteMentorSlot(Long slotId, Member member) { } + // ===== 반복 슬롯 생성 로직 ===== + + /** + * 특정 요일에 해당하는 모든 슬롯들을 생성 + */ + private List generateSlotsForDayOfWeek(MentorSlotRepetitionRequest reqDto, DayOfWeek targetDayOfWeek, Mentor mentor) { + List mentorSlots = new ArrayList<>(); + LocalDate currentDate = findNextOrSameDayOfWeek(reqDto.repeatStartDate(), targetDayOfWeek); + + // 해당 요일에 대해 주 단위로 반복하여 슬롯 생성 + while (!currentDate.isAfter(reqDto.repeatEndDate())) { + LocalDateTime startDateTime = LocalDateTime.of(currentDate, reqDto.startTime()); + LocalDateTime endDateTime = LocalDateTime.of(currentDate, reqDto.endTime()); + + validateOverlappingSlots(mentor, startDateTime, endDateTime); + + MentorSlot mentorSlot = MentorSlot.builder() + .mentor(mentor) + .startDateTime(startDateTime) + .endDateTime(endDateTime) + .build(); + mentorSlots.add(mentorSlot); + + currentDate = currentDate.plusWeeks(1); + } + return mentorSlots; + } + + /** + * 시작일부터 해당 요일의 첫 번째 날짜를 찾기 + */ + private LocalDate findNextOrSameDayOfWeek(LocalDate startDate, DayOfWeek targetDay) { + return startDate.with(TemporalAdjusters.nextOrSame(targetDay)); + } + + // ===== 헬퍼 메서드 ===== - private Mentor findMentor(Member member) { + private Mentor findMentorByMember(Member member) { return mentorRepository.findByMemberId(member.getId()) .orElseThrow(() -> new ServiceException(MentoringErrorCode.NOT_FOUND_MENTOR)); } @@ -115,6 +195,12 @@ private static void validateOwner(MentorSlot mentorSlot, Mentor mentor) { } } + private void validateMentorExists(Long mentorId) { + if (!mentorRepository.existsById(mentorId)) { + throw new ServiceException(MentoringErrorCode.NOT_FOUND_MENTOR); + } + } + /** * 주어진 시간대가 기존 슬롯과 겹치는지 검증 * - mentor의 기존 모든 슬롯과 비교 diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java index 4d49de0a..e9019655 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java @@ -50,7 +50,7 @@ class MentoringControllerTest { @Autowired private AuthTokenService authTokenService; private static final String TOKEN = "accessToken"; - private static final String MENTORING_URL = "/mentoring"; + private static final String MENTORING_URL = "/mentorings"; private Mentor mentor; private Mentee mentee; @@ -133,22 +133,13 @@ void getMentoringsSuccessSearchMentor() throws Exception { @Test @DisplayName("멘토링 다건 조회 - 멘토링 검색") void getMentoringsSuccessSearchMentoring() throws Exception { - mentoringFixture.createMentorings(mentor, 20); - performGetMentorings("테스트 멘토링 1", "0") - .andExpect(jsonPath("$.data.mentorings").isArray()) - .andExpect(jsonPath("$.data.mentorings.length()").value(10)) - .andExpect(jsonPath("$.data.currentPage").value(0)); - /*.andExpect(jsonPath("$.data.totalPage").value(2)) - .andExpect(jsonPath("$.data.totalElements").value(11)) - .andExpect(jsonPath("$.data.hasNext").value(true));*/ + .andExpect(jsonPath("$.data.mentorings").isArray()); } @Test @DisplayName("멘토링 다건 조회 - 검색 결과 없는 경우") void getMentoringsSuccessSearchEmpty() throws Exception { - mentoringFixture.createMentorings(mentor, 2); - performGetMentorings("mentee", "0") .andExpect(jsonPath("$.data.mentorings").isArray()) .andExpect(jsonPath("$.data.mentorings.length()").value(0)) 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 512b1cc1..179311c2 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 @@ -25,10 +25,13 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; +import java.time.DayOfWeek; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -49,7 +52,7 @@ class MentorSlotControllerTest { @Autowired private AuthTokenService authTokenService; private static final String TOKEN = "accessToken"; - private static final String MENTOR_SLOT_URL = "/mentor-slot"; + private static final String MENTOR_SLOT_URL = "/mentor-slots"; private Mentor mentor; private String mentorToken; @@ -73,6 +76,73 @@ void setUp() { mentorSlots = mentoringFixture.createMentorSlots(mentor, baseDateTime, 2, 3); } + // ===== 슬롯 목록 조회 ===== + @Test + @DisplayName("멘토가 본인의 모든 슬롯 목록 조회 성공") + void getMyMentorSlotsSuccess() throws Exception { + // 캘린더 기준 (월) + LocalDateTime startDate = LocalDateTime.of(2025, 8, 31, 0, 0); + LocalDateTime endDate = LocalDateTime.of(2025, 10, 5, 0, 0); + + // 경계값 + mentoringFixture.createMentorSlot(mentor, endDate.minusMinutes(1), endDate.plusMinutes(10)); + mentoringFixture.createMentorSlot(mentor, startDate.minusMinutes(10), startDate); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + ResultActions resultActions = mvc.perform( + get(MENTOR_SLOT_URL) + .cookie(new Cookie(TOKEN, mentorToken)) + .param("startDate", startDate.format(formatter)) + .param("endDate", endDate.format(formatter)) + ) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(handler().handlerType(MentorSlotController.class)) + .andExpect(handler().methodName("getMyMentorSlots")) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("나의 모든 일정 목록을 조회하였습니다.")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(8)); + } + + @Test + @DisplayName("멘토의 예약 가능한 슬롯 목록 조회(멘티) 성공") + void getAvailableMentorSlotsSuccess() throws Exception { + // 캘린더 기준 (월) + LocalDateTime startDate = LocalDateTime.of(2025, 8, 31, 0, 0); + LocalDateTime endDate = LocalDateTime.of(2025, 10, 5, 0, 0); + + // 경계값 + mentoringFixture.createMentorSlot(mentor, endDate.minusMinutes(1), endDate.plusMinutes(10)); + mentoringFixture.createMentorSlot(mentor, startDate.minusMinutes(10), startDate); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + Member menteeMember = memberFixture.createMenteeMember(); + String token = authTokenService.genAccessToken(menteeMember); + + ResultActions resultActions = mvc.perform( + get(MENTOR_SLOT_URL + "/available/" + mentor.getId()) + .cookie(new Cookie(TOKEN, token)) + .param("startDate", startDate.format(formatter)) + .param("endDate", endDate.format(formatter)) + ) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(handler().handlerType(MentorSlotController.class)) + .andExpect(handler().methodName("getAvailableMentorSlots")) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.msg").value("멘토의 예약 가능 일정 목록을 조회하였습니다.")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(8)); + } + + // ===== 슬롯 조회 ===== @Test @DisplayName("멘토 슬롯 조회 성공") @@ -159,6 +229,60 @@ void createMentorSlotFailOverlappingSlots() throws Exception { } + // ===== 슬롯 반복 생성 ===== + @Test + @DisplayName("멘토 슬롯 반복 생성 성공") + void createMentorSlotRepetitionSuccess() throws Exception { + String req = """ + { + "repeatStartDate": "2025-11-01", + "repeatEndDate": "2025-11-30", + "daysOfWeek": ["MONDAY", "WEDNESDAY", "FRIDAY"], + "startTime": "10:00:00", + "endTime": "11:00:00" + } + """; + + long beforeCount = mentorSlotRepository.countByMentorId(mentor.getId()); + + mvc.perform( + post(MENTOR_SLOT_URL + "/repetition") + .cookie(new Cookie(TOKEN, mentorToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(req) + ) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.resultCode").value("201")) + .andExpect(jsonPath("$.msg").value("반복 일정을 등록했습니다.")); + + // 11월 월/수/금 = 13개 + long afterCount = mentorSlotRepository.countByMentorId(mentor.getId()); + assertThat(afterCount - beforeCount).isEqualTo(12); + + List createdSlots = mentorSlotRepository.findMySlots( + mentor.getId(), + LocalDateTime.of(2025, 11, 1, 0, 0), + LocalDateTime.of(2025, 12, 1, 0, 0) + ); + assertThat(createdSlots).hasSize(12); + + // 모든 슬롯이 월/수/금인지 검증 + Set actualDaysOfWeek = createdSlots.stream() + .map(slot -> slot.getStartDateTime().getDayOfWeek()) + .collect(Collectors.toSet()); + + assertThat(actualDaysOfWeek).containsExactlyInAnyOrder( + DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY + ); + + // 시간 검증 + MentorSlot firstSlot = createdSlots.getFirst(); + assertThat(firstSlot.getStartDateTime().getHour()).isEqualTo(10); + assertThat(firstSlot.getEndDateTime().getHour()).isEqualTo(11); + } + + // ===== 슬롯 수정 ===== @Test 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/DateTimeValidatorTest.java similarity index 81% rename from back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotValidatorTest.java rename to back/src/test/java/com/back/domain/mentoring/slot/service/DateTimeValidatorTest.java index 0984cb7f..7080cfbc 100644 --- a/back/src/test/java/com/back/domain/mentoring/slot/service/MentorSlotValidatorTest.java +++ b/back/src/test/java/com/back/domain/mentoring/slot/service/DateTimeValidatorTest.java @@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.*; -class MentorSlotValidatorTest { +class DateTimeValidatorTest { @Test @DisplayName("시작 일시, 종료 일시 기입 시 정상 처리") @@ -19,7 +19,7 @@ void validateNotNull_success() { LocalDateTime end = LocalDateTime.now().plusHours(2); // when & then - assertDoesNotThrow(() -> MentorSlotValidator.validateNotNull(start, end)); + assertDoesNotThrow(() -> DateTimeValidator.validateNotNull(start, end)); } @Test @@ -30,7 +30,7 @@ void validateNotNull_fail_startNull() { // when & then ServiceException exception = assertThrows(ServiceException.class, - () -> MentorSlotValidator.validateNotNull(null, end)); + () -> DateTimeValidator.validateNotNull(null, end)); assertEquals(MentorSlotErrorCode.START_TIME_REQUIRED.getCode(), exception.getResultCode()); } @@ -43,48 +43,48 @@ void validateNotNull_fail_endNull() { // when & then ServiceException exception = assertThrows(ServiceException.class, - () -> MentorSlotValidator.validateNotNull(start, null)); + () -> DateTimeValidator.validateNotNull(start, null)); assertEquals(MentorSlotErrorCode.END_TIME_REQUIRED.getCode(), exception.getResultCode()); } @Test - @DisplayName("현재 이후의 시작 일시, 종료 일시 기입 시 정상 처리") - void validateTimeRange_success() { + @DisplayName("종료 일시가 시작 일시보다 이전이면 예외 발생") + void validateEndTimeAfterStart_fail() { // given - LocalDateTime start = LocalDateTime.now().plusHours(1); - LocalDateTime end = start.plusHours(1); + LocalDateTime start = LocalDateTime.now().plusHours(2); + LocalDateTime end = LocalDateTime.now().plusHours(1); // when & then - assertDoesNotThrow(() -> MentorSlotValidator.validateTimeRange(start, end)); + ServiceException exception = assertThrows(ServiceException.class, + () -> DateTimeValidator.validateEndTimeAfterStart(start, end)); + + assertEquals(MentorSlotErrorCode.END_TIME_BEFORE_START.getCode(), exception.getResultCode()); } @Test - @DisplayName("시작 일시가 현재보다 이전이면 예외 발생") - void validateTimeRange_fail_startTimeInPast() { + @DisplayName("현재 이후의 시작 일시, 종료 일시 기입 시 정상 처리") + void validateStartTimeNotInPast_success() { // given - LocalDateTime start = LocalDateTime.now().minusHours(1); - LocalDateTime end = LocalDateTime.now().plusHours(1); + LocalDateTime start = LocalDateTime.now().plusHours(1); + LocalDateTime end = start.plusHours(1); // when & then - ServiceException exception = assertThrows(ServiceException.class, - () -> MentorSlotValidator.validateTimeRange(start, end)); - - assertEquals(MentorSlotErrorCode.START_TIME_IN_PAST.getCode(), exception.getResultCode()); + assertDoesNotThrow(() -> DateTimeValidator.validateStartTimeNotInPast(start)); } @Test - @DisplayName("종료 일시가 시작 일시보다 이전이면 예외 발생") - void validateTimeRange_fail_endTimeBeforeStart() { + @DisplayName("시작 일시가 현재보다 이전이면 예외 발생") + void validateStartTimeNotInPast_fail() { // given - LocalDateTime start = LocalDateTime.now().plusHours(2); + LocalDateTime start = LocalDateTime.now().minusHours(1); LocalDateTime end = LocalDateTime.now().plusHours(1); // when & then ServiceException exception = assertThrows(ServiceException.class, - () -> MentorSlotValidator.validateTimeRange(start, end)); + () -> DateTimeValidator.validateStartTimeNotInPast(start)); - assertEquals(MentorSlotErrorCode.END_TIME_BEFORE_START.getCode(), exception.getResultCode()); + assertEquals(MentorSlotErrorCode.START_TIME_IN_PAST.getCode(), exception.getResultCode()); } @Test @@ -95,7 +95,7 @@ void validateMinimumDuration_success() { LocalDateTime end = start.plusHours(1); // when & then - assertDoesNotThrow(() -> MentorSlotValidator.validateMinimumDuration(start, end)); + assertDoesNotThrow(() -> DateTimeValidator.validateMinimumDuration(start, end)); } @Test @@ -106,7 +106,7 @@ void validateMinimumDuration_success_exactly20Minutes() { LocalDateTime end = start.plusMinutes(20); // when & then - assertDoesNotThrow(() -> MentorSlotValidator.validateMinimumDuration(start, end)); + assertDoesNotThrow(() -> DateTimeValidator.validateMinimumDuration(start, end)); } @Test @@ -118,7 +118,7 @@ void validateMinimumDuration_fail_insufficientSlotDuration() { // when & then ServiceException exception = assertThrows(ServiceException.class, - () -> MentorSlotValidator.validateMinimumDuration(start, end)); + () -> DateTimeValidator.validateMinimumDuration(start, end)); assertEquals(MentorSlotErrorCode.INSUFFICIENT_SLOT_DURATION.getCode(), exception.getResultCode()); } @@ -131,7 +131,7 @@ void validateTimeSlot_success() { LocalDateTime end = start.plusMinutes(30); // when & then - assertDoesNotThrow(() -> MentorSlotValidator.validateTimeSlot(start, end)); + assertDoesNotThrow(() -> DateTimeValidator.validateTimeSlot(start, end)); } @Test @@ -142,7 +142,7 @@ void validateTimeSlot_fail_nullCheck() { // when & then ServiceException exception = assertThrows(ServiceException.class, - () -> MentorSlotValidator.validateTimeSlot(null, end)); + () -> DateTimeValidator.validateTimeSlot(null, end)); assertEquals(MentorSlotErrorCode.START_TIME_REQUIRED.getCode(), exception.getResultCode()); }