diff --git a/src/main/java/com/example/rels/domain/auth/controller/AuthController.java b/src/main/java/com/example/rels/domain/auth/controller/AuthController.java index 8b8e1eb..34d3098 100644 --- a/src/main/java/com/example/rels/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/rels/domain/auth/controller/AuthController.java @@ -15,6 +15,7 @@ import com.example.rels.domain.auth.dto.CurrentUserResponse; import com.example.rels.domain.auth.dto.OAuthSignInRequest; import com.example.rels.domain.auth.dto.OAuthSignInResponse; +import com.example.rels.domain.auth.dto.RefreshTokenRequest; import com.example.rels.domain.auth.service.AuthService; import com.example.rels.domain.auth.service.DgOAuthFlowService; import com.example.rels.global.security.AuthenticatedUser; @@ -47,6 +48,11 @@ public OAuthSignInResponse signIn(@Valid @RequestBody OAuthSignInRequest request return authService.signIn(request); } + @PostMapping("/refresh") + public OAuthSignInResponse refresh(@Valid @RequestBody RefreshTokenRequest request) { + return authService.refresh(request); + } + @GetMapping("/me") public CurrentUserResponse me(@AuthenticationPrincipal AuthenticatedUser currentUser) { if (currentUser == null) { diff --git a/src/main/java/com/example/rels/domain/lecture/controller/LectureController.java b/src/main/java/com/example/rels/domain/lecture/controller/LectureController.java index a96e29b..93fb5a7 100644 --- a/src/main/java/com/example/rels/domain/lecture/controller/LectureController.java +++ b/src/main/java/com/example/rels/domain/lecture/controller/LectureController.java @@ -1,6 +1,7 @@ package com.example.rels.domain.lecture.controller; import com.example.rels.domain.lecture.dto.request.AttendanceUpdateRequest; +import com.example.rels.domain.lecture.dto.request.EnrollmentDecisionRequest; import com.example.rels.domain.lecture.dto.request.LectureApprovalRequest; import com.example.rels.domain.lecture.dto.request.LectureCreateRequest; import com.example.rels.domain.lecture.dto.request.LectureUpdateRequest; @@ -122,8 +123,21 @@ public EnrollmentResponse cancelEnrollment( } @GetMapping("/{lectureId}/enrollments") - public EnrollmentListResponse getEnrollments(@PathVariable Long lectureId) { - return lectureService.getEnrollments(lectureId); + public EnrollmentListResponse getEnrollments( + @PathVariable Long lectureId, + @AuthenticationPrincipal AuthenticatedUser currentUser) { + AuthenticatedUser authenticatedUser = requireUser(currentUser); + return lectureService.getEnrollments(lectureId, authenticatedUser.userId(), authenticatedUser.role()); + } + + @PatchMapping("/{lectureId}/enrollments/{userId}/decision") + public EnrollmentResponse decideWaitingEnrollment( + @PathVariable Long lectureId, + @PathVariable Long userId, + @AuthenticationPrincipal AuthenticatedUser currentUser, + @Valid @RequestBody EnrollmentDecisionRequest request) { + AuthenticatedUser authenticatedUser = requireUser(currentUser); + return lectureService.decideWaitingEnrollment(lectureId, userId, authenticatedUser.userId(), authenticatedUser.role(), request); } @GetMapping("/enrollments/me") @@ -156,4 +170,4 @@ private AuthenticatedUser requireUser(AuthenticatedUser currentUser) { } return currentUser; } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/rels/domain/lecture/dto/request/EnrollmentDecisionRequest.java b/src/main/java/com/example/rels/domain/lecture/dto/request/EnrollmentDecisionRequest.java new file mode 100644 index 0000000..4eefd6c --- /dev/null +++ b/src/main/java/com/example/rels/domain/lecture/dto/request/EnrollmentDecisionRequest.java @@ -0,0 +1,8 @@ +package com.example.rels.domain.lecture.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record EnrollmentDecisionRequest( + @NotNull Boolean approved +) { +} diff --git a/src/main/java/com/example/rels/domain/lecture/dto/request/LectureCreateRequest.java b/src/main/java/com/example/rels/domain/lecture/dto/request/LectureCreateRequest.java index cc6f0d5..17dbf77 100644 --- a/src/main/java/com/example/rels/domain/lecture/dto/request/LectureCreateRequest.java +++ b/src/main/java/com/example/rels/domain/lecture/dto/request/LectureCreateRequest.java @@ -1,7 +1,6 @@ package com.example.rels.domain.lecture.dto.request; - - +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -11,16 +10,17 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Map; - +import java.util.Set; public record LectureCreateRequest( - @NotBlank @Size(max = 100) String title, - @NotBlank @Size(max = 800) String description, - Map<@NotNull @Min(1) @Max(3) Integer, @NotNull @Min(0) Integer> capacityByGrade, - Integer totalCapacity, - @NotBlank @Size(max = 255) String lectureLocation, - @NotNull LocalDate lectureDate, - @NotNull LocalTime lectureTime, - @NotNull LocalDateTime applicationDeadline + @NotBlank @Size(max = 100) String title, + @NotBlank @Size(max = 800) String description, + Map<@NotNull @Min(1) @Max(3) Integer, @NotNull @Min(0) Integer> capacityByGrade, + Integer totalCapacity, + @NotBlank @Size(max = 255) String lectureLocation, + @NotNull LocalDate lectureDate, + @NotNull LocalTime lectureTime, + @NotNull @Future(message = "신청 마감 시각은 현재보다 미래여야 합니다.") LocalDateTime applicationDeadline, + Set<@NotNull Long> speakerIds ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/example/rels/domain/lecture/dto/request/LectureUpdateRequest.java b/src/main/java/com/example/rels/domain/lecture/dto/request/LectureUpdateRequest.java index 7c3650e..34eddf0 100644 --- a/src/main/java/com/example/rels/domain/lecture/dto/request/LectureUpdateRequest.java +++ b/src/main/java/com/example/rels/domain/lecture/dto/request/LectureUpdateRequest.java @@ -1,7 +1,5 @@ package com.example.rels.domain.lecture.dto.request; - - import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -11,16 +9,17 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Map; - +import java.util.Set; public record LectureUpdateRequest( - @NotBlank @Size(max = 100) String title, - @NotBlank @Size(max = 800) String description, - Map<@NotNull @Min(1) @Max(3) Integer, @NotNull @Min(0) Integer> capacityByGrade, - Integer totalCapacity, - @NotBlank @Size(max = 255) String lectureLocation, - @NotNull LocalDate lectureDate, - @NotNull LocalTime lectureTime, - @NotNull LocalDateTime applicationDeadline + @NotBlank @Size(max = 100) String title, + @NotBlank @Size(max = 800) String description, + Map<@NotNull @Min(1) @Max(3) Integer, @NotNull @Min(0) Integer> capacityByGrade, + Integer totalCapacity, + @NotBlank @Size(max = 255) String lectureLocation, + @NotNull LocalDate lectureDate, + @NotNull LocalTime lectureTime, + @NotNull LocalDateTime applicationDeadline, + Set<@NotNull Long> speakerIds ) { } diff --git a/src/main/java/com/example/rels/domain/lecture/dto/response/EnrollmentListResponse.java b/src/main/java/com/example/rels/domain/lecture/dto/response/EnrollmentListResponse.java index f6377de..02aed8e 100644 --- a/src/main/java/com/example/rels/domain/lecture/dto/response/EnrollmentListResponse.java +++ b/src/main/java/com/example/rels/domain/lecture/dto/response/EnrollmentListResponse.java @@ -4,6 +4,7 @@ public record EnrollmentListResponse( List enrolled, - List waiting + List waiting, + List rejected ) {} diff --git a/src/main/java/com/example/rels/domain/lecture/dto/response/LectureDetailResponse.java b/src/main/java/com/example/rels/domain/lecture/dto/response/LectureDetailResponse.java index 7ae022b..1a52f3e 100644 --- a/src/main/java/com/example/rels/domain/lecture/dto/response/LectureDetailResponse.java +++ b/src/main/java/com/example/rels/domain/lecture/dto/response/LectureDetailResponse.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Map; +import java.util.List; @@ -15,6 +16,7 @@ public record LectureDetailResponse( Long creatorId, String creatorName, String creatorStudentNumber, + List speakers, String lectureStatus, String approvalStatus, String rejectionReason, @@ -26,6 +28,7 @@ public record LectureDetailResponse( LocalTime lectureTime, LocalDateTime applicationDeadline, LocalDateTime createdAt, + LocalDateTime approvedAt, Map capacityByGrade, Integer totalCapacity ) { diff --git a/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSpeakerResponse.java b/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSpeakerResponse.java new file mode 100644 index 0000000..e370a4a --- /dev/null +++ b/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSpeakerResponse.java @@ -0,0 +1,8 @@ +package com.example.rels.domain.lecture.dto.response; + +public record LectureSpeakerResponse( + Long userId, + String name, + String studentNumber +) { +} diff --git a/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSummaryResponse.java b/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSummaryResponse.java index 104c781..82c14a4 100644 --- a/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSummaryResponse.java +++ b/src/main/java/com/example/rels/domain/lecture/dto/response/LectureSummaryResponse.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Map; +import java.util.List; public record LectureSummaryResponse( @@ -13,6 +14,7 @@ public record LectureSummaryResponse( Long creatorId, String creatorName, String creatorStudentNumber, + List speakers, String lectureStatus, String approvalStatus, String rejectionReason, @@ -23,6 +25,7 @@ public record LectureSummaryResponse( LocalTime lectureTime, LocalDateTime applicationDeadline, LocalDateTime createdAt, + LocalDateTime approvedAt, Map capacityByGrade, Integer totalCapacity ) { diff --git a/src/main/java/com/example/rels/domain/lecture/dto/response/MyCreatedLectureResponse.java b/src/main/java/com/example/rels/domain/lecture/dto/response/MyCreatedLectureResponse.java index 216c03b..3d85804 100644 --- a/src/main/java/com/example/rels/domain/lecture/dto/response/MyCreatedLectureResponse.java +++ b/src/main/java/com/example/rels/domain/lecture/dto/response/MyCreatedLectureResponse.java @@ -7,6 +7,7 @@ public record MyCreatedLectureResponse( Long lectureId, String title, + boolean creator, String lectureStatus, String approvalStatus, String rejectionReason, diff --git a/src/main/java/com/example/rels/domain/lecture/entity/EnrollmentStatus.java b/src/main/java/com/example/rels/domain/lecture/entity/EnrollmentStatus.java index 752c0fd..b51ef4b 100644 --- a/src/main/java/com/example/rels/domain/lecture/entity/EnrollmentStatus.java +++ b/src/main/java/com/example/rels/domain/lecture/entity/EnrollmentStatus.java @@ -2,6 +2,7 @@ public enum EnrollmentStatus { ENROLLED, - WAITING + WAITING, + REJECTED } diff --git a/src/main/java/com/example/rels/domain/lecture/entity/LectureEnrollmentEntity.java b/src/main/java/com/example/rels/domain/lecture/entity/LectureEnrollmentEntity.java index bebb7ae..a708270 100644 --- a/src/main/java/com/example/rels/domain/lecture/entity/LectureEnrollmentEntity.java +++ b/src/main/java/com/example/rels/domain/lecture/entity/LectureEnrollmentEntity.java @@ -81,6 +81,10 @@ public void promoteToEnrolled() { this.status = EnrollmentStatus.ENROLLED; } + public void reject() { + this.status = EnrollmentStatus.REJECTED; + } + @Enumerated(EnumType.STRING) @Column(nullable = false) private AttendanceStatus attendanceStatus = AttendanceStatus.NONE; diff --git a/src/main/java/com/example/rels/domain/lecture/entity/LectureEntity.java b/src/main/java/com/example/rels/domain/lecture/entity/LectureEntity.java index d696cf4..a03ad22 100644 --- a/src/main/java/com/example/rels/domain/lecture/entity/LectureEntity.java +++ b/src/main/java/com/example/rels/domain/lecture/entity/LectureEntity.java @@ -1,21 +1,21 @@ package com.example.rels.domain.lecture.entity; -import java.util.HashMap; -import java.util.Map; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.CollectionTable; -import jakarta.persistence.MapKeyColumn; - import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import com.example.rels.domain.user.entity.UserEntity; +import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -24,12 +24,16 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapKeyColumn; import jakarta.persistence.Table; @Entity @Table(name = "lectures") public class LectureEntity { + @ElementCollection @CollectionTable(name = "lecture_capacity_by_grade", joinColumns = @JoinColumn(name = "lecture_id")) @MapKeyColumn(name = "grade") @@ -50,6 +54,12 @@ public class LectureEntity { @JoinColumn(name = "creator_id", nullable = false) private UserEntity creator; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "lecture_speakers", + joinColumns = @JoinColumn(name = "lecture_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set speakers = new LinkedHashSet<>(); + @Enumerated(EnumType.STRING) @Column(nullable = false) private LectureStatus status; @@ -63,7 +73,7 @@ public class LectureEntity { @Column(name = "lecture_time") private LocalTime lectureTime; - @Column(name = "application_deadline", nullable = false) + @Column(name = "application_deadline") private LocalDateTime applicationDeadline; @Column(name = "total_capacity") @@ -84,6 +94,9 @@ public class LectureEntity { @Column(name = "rejection_reason") private String rejectionReason; + @Column(name = "approved_at") + private LocalDateTime approvedAt; + protected LectureEntity() { } @@ -91,6 +104,7 @@ public LectureEntity(String title, String description, UserEntity creator, Strin this.title = title; this.description = description; this.creator = creator; + this.speakers.add(creator); this.status = LectureStatus.OPEN; this.capacityByGrade = new HashMap<>(); this.lectureLocation = lectureLocation; @@ -134,6 +148,22 @@ public UserEntity getCreator() { return creator; } + public Set getSpeakers() { + return Set.copyOf(speakers); + } + + public boolean isSpeaker(Long userId) { + return userId != null && speakers.stream().anyMatch(speaker -> speaker.getId().equals(userId)); + } + + public void updateSpeakers(Set speakers) { + this.speakers.clear(); + this.speakers.add(creator); + if (speakers != null) { + this.speakers.addAll(speakers); + } + } + public LectureStatus getStatus() { return status; } @@ -205,8 +235,15 @@ public String getRejectionReason() { return rejectionReason; } + public LocalDateTime getApprovedAt() { + return approvedAt; + } + public void updateApprovalStatus(ApprovalStatus approvalStatus, String rejectionReason) { this.approvalStatus = approvalStatus; this.rejectionReason = rejectionReason; + if (approvalStatus == ApprovalStatus.APPROVED) { + this.approvedAt = LocalDateTime.now(); + } } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/rels/domain/lecture/repository/LectureRepository.java b/src/main/java/com/example/rels/domain/lecture/repository/LectureRepository.java index c8a6b93..64cdc2e 100644 --- a/src/main/java/com/example/rels/domain/lecture/repository/LectureRepository.java +++ b/src/main/java/com/example/rels/domain/lecture/repository/LectureRepository.java @@ -17,19 +17,21 @@ public interface LectureRepository extends JpaRepository { - @EntityGraph(attributePaths = "creator") + @EntityGraph(attributePaths = {"creator", "speakers"}) Page findAllByOrderByCreatedAtDesc(Pageable pageable); - @EntityGraph(attributePaths = "creator") + @EntityGraph(attributePaths = {"creator", "speakers"}) Page findAllByApprovalStatusOrderByCreatedAtDesc(ApprovalStatus approvalStatus, Pageable pageable); - // 승인된 강연 + 조회한 본인이 개설한 강연(승인 대기/거절 포함) - @EntityGraph(attributePaths = "creator") - Page findAllByApprovalStatusOrCreatorIdOrderByCreatedAtDesc(ApprovalStatus approvalStatus, Long creatorId, Pageable pageable); + @EntityGraph(attributePaths = {"creator", "speakers"}) + @Query("select distinct l from LectureEntity l left join l.speakers s where l.approvalStatus = :approvalStatus or l.creator.id = :userId or s.id = :userId") + Page findVisibleToUser(ApprovalStatus approvalStatus, Long userId, Pageable pageable); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select l from LectureEntity l where l.id = :lectureId") Optional findByIdForUpdate(Long lectureId); - List findAllByCreatorIdOrderByCreatedAtDesc(Long creatorId); -} \ No newline at end of file + @EntityGraph(attributePaths = {"creator", "speakers"}) + @Query("select distinct l from LectureEntity l join l.speakers s where s.id = :userId order by l.createdAt desc") + List findAllBySpeakerIdOrderByCreatedAtDesc(Long userId); +} diff --git a/src/main/java/com/example/rels/domain/lecture/service/LectureLifecycleHandler.java b/src/main/java/com/example/rels/domain/lecture/service/LectureLifecycleHandler.java new file mode 100644 index 0000000..82e9041 --- /dev/null +++ b/src/main/java/com/example/rels/domain/lecture/service/LectureLifecycleHandler.java @@ -0,0 +1,156 @@ +package com.example.rels.domain.lecture.service; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.stereotype.Component; + +import com.example.rels.domain.lecture.entity.EnrollmentStatus; +import com.example.rels.domain.lecture.entity.LectureEnrollmentEntity; +import com.example.rels.domain.lecture.entity.LectureEntity; +import com.example.rels.domain.lecture.entity.LectureStatus; +import com.example.rels.domain.lecture.repository.LectureEnrollmentRepository; + +@Component +public class LectureLifecycleHandler { + + private static final long CONFIRM_THRESHOLD = 10; + private final LectureEnrollmentRepository lectureEnrollmentRepository; + + public LectureLifecycleHandler(LectureEnrollmentRepository lectureEnrollmentRepository) { + this.lectureEnrollmentRepository = lectureEnrollmentRepository; + } + + public void promoteFirstWaitingUser(LectureEntity lecture, LocalDateTime now) { + Long lectureId = lecture.getId(); + List enrollments = lectureEnrollmentRepository.findAllByLectureId(lectureId); + + List enrolled = enrollments.stream() + .filter(e -> e.getStatus() == EnrollmentStatus.ENROLLED) + .toList(); + + int capacity = resolveTotalCapacity(lecture); + if (capacity > 0 && enrolled.size() >= capacity) { + return; + } + + List waiting = sortByRequestedOrder(enrollments.stream() + .filter(e -> e.getStatus() == EnrollmentStatus.WAITING) + .toList()); + if (waiting.isEmpty()) { + return; + } + + Map capacityByGrade = lecture.getCapacityByGrade(); + boolean useGradeCapacity = capacityByGrade != null && !capacityByGrade.isEmpty() + && !isAfterApplicationDeadline(lecture, now); + + if (!useGradeCapacity) { + waiting.get(0).promoteToEnrolled(); + return; + } + + for (LectureEnrollmentEntity candidate : waiting) { + Integer grade = extractGradeFromStudentNumber(candidate.getUser().getStudentNumber()); + Integer gradeCapacity = grade == null ? null : capacityByGrade.get(grade); + if (gradeCapacity == null) continue; + + long taken = enrolled.stream() + .filter(e -> grade.equals(extractGradeFromStudentNumber(e.getUser().getStudentNumber()))) + .count(); + if (taken < gradeCapacity) { + candidate.promoteToEnrolled(); + return; + } + } + } + + public void promoteWaitingAfterDeadline(LectureEntity lecture, LocalDateTime now) { + if (lecture.getId() == null || lecture.getStatus() == LectureStatus.CLOSE) return; + if (!isAfterApplicationDeadline(lecture, now)) return; + + int capacity = resolveTotalCapacity(lecture); + if (capacity <= 0) return; + + List enrollments = lectureEnrollmentRepository.findAllByLectureId(lecture.getId()); + long enrolledCount = enrollments.stream().filter(e -> e.getStatus() == EnrollmentStatus.ENROLLED).count(); + if (enrolledCount >= capacity) return; + + List waiting = sortByRequestedOrder(enrollments.stream() + .filter(e -> e.getStatus() == EnrollmentStatus.WAITING) + .toList()); + + for (LectureEnrollmentEntity enrollment : waiting) { + if (enrolledCount >= capacity) break; + enrollment.promoteToEnrolled(); + enrolledCount++; + } + } + + public void refreshLectureLifecycle(LectureEntity lecture, LocalDateTime now) { + if (lecture.getId() == null) { + LocalDateTime lectureEndDateTime = lecture.getLectureEndDateTime(); + if (lecture.getStatus() != LectureStatus.CLOSE && lectureEndDateTime != null && now.isAfter(lectureEndDateTime)) { + lecture.close(); + } + return; + } + + long enrolledCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lecture.getId(), EnrollmentStatus.ENROLLED); + refreshLectureLifecycle(lecture, now, enrolledCount); + } + + public void refreshLectureLifecycle(LectureEntity lecture, LocalDateTime now, long enrolledCount) { + if (lecture.getStatus() == LectureStatus.CLOSE) return; + + LocalDateTime lectureEndDateTime = lecture.getLectureEndDateTime(); + if (lectureEndDateTime != null && now.isAfter(lectureEndDateTime)) { + lecture.close(); + return; + } + + if (lecture.getStatus() != LectureStatus.OPEN) return; + + if (lecture.getApplicationDeadline() != null && now.isAfter(lecture.getApplicationDeadline())) { + if (enrolledCount >= CONFIRM_THRESHOLD) { + lecture.confirm(); + } else { + lecture.setStatus(LectureStatus.UNCONFIRMED); + } + } + } + + public int resolveTotalCapacity(LectureEntity lecture) { + if (lecture.getTotalCapacity() != null) return lecture.getTotalCapacity(); + Map capacityByGrade = lecture.getCapacityByGrade(); + if (capacityByGrade == null || capacityByGrade.isEmpty()) return 0; + + return capacityByGrade.values().stream() + .filter(Objects::nonNull) + .mapToInt(Integer::intValue) + .sum(); + } + + public Integer extractGradeFromStudentNumber(String studentNumber) { + if (studentNumber == null || studentNumber.isEmpty()) return null; + try { + return Integer.parseInt(studentNumber.substring(0, 1)); + } catch (Exception e) { + return null; + } + } + + private boolean isAfterApplicationDeadline(LectureEntity lecture, LocalDateTime now) { + return lecture.getApplicationDeadline() != null && now.isAfter(lecture.getApplicationDeadline()); + } + + private List sortByRequestedOrder(List enrollments) { + return enrollments.stream() + .sorted(Comparator.comparing(LectureEnrollmentEntity::getRequestedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(LectureEnrollmentEntity::getId, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/rels/domain/lecture/service/LectureService.java b/src/main/java/com/example/rels/domain/lecture/service/LectureService.java index 18b9755..196095e 100644 --- a/src/main/java/com/example/rels/domain/lecture/service/LectureService.java +++ b/src/main/java/com/example/rels/domain/lecture/service/LectureService.java @@ -1,16 +1,15 @@ package com.example.rels.domain.lecture.service; import java.time.LocalDateTime; -import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; -import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import com.example.rels.domain.lecture.dto.request.AttendanceUpdateRequest; +import com.example.rels.domain.lecture.dto.request.EnrollmentDecisionRequest; import com.example.rels.domain.lecture.dto.request.LectureApprovalRequest; import com.example.rels.domain.lecture.dto.request.LectureCreateRequest; import com.example.rels.domain.lecture.dto.request.LectureUpdateRequest; @@ -44,29 +43,34 @@ public class LectureService { */ private static final ZoneId SCHOOL_ZONE = ZoneId.of("Asia/Seoul"); - /** 7교시가 끝나는 시각. 수강 신청은 이 시각부터 받는다. */ - private static final LocalTime ENROLLMENT_OPEN_TIME = LocalTime.of(16, 20); - private static final long CONFIRM_THRESHOLD = 10; private static final int MIN_CAPACITY = 10; - private static final int MAX_CAPACITY = 30; private final LectureRepository lectureRepository; private final LectureEnrollmentRepository lectureEnrollmentRepository; private final UserRepository userRepository; + private final LectureTimeValidator timeValidator; + private final LectureLifecycleHandler lifecycleHandler; public LectureService(LectureRepository lectureRepository, LectureEnrollmentRepository lectureEnrollmentRepository, - UserRepository userRepository) { + UserRepository userRepository, + LectureTimeValidator timeValidator, + LectureLifecycleHandler lifecycleHandler) { this.lectureRepository = lectureRepository; this.lectureEnrollmentRepository = lectureEnrollmentRepository; this.userRepository = userRepository; + this.timeValidator = timeValidator; + this.lifecycleHandler = lifecycleHandler; } @Transactional public LectureDetailResponse createLecture(Long userId, LectureCreateRequest request) { validateLectureCapacityRules(request.capacityByGrade(), request.totalCapacity()); + timeValidator.validateApplicationDeadline(request.lectureDate(), request.lectureTime(), request.applicationDeadline()); + UserEntity creator = requireUser(userId); + LectureEntity lecture = new LectureEntity( request.title(), request.description(), @@ -78,6 +82,7 @@ public LectureDetailResponse createLecture(Long userId, LectureCreateRequest req request.totalCapacity() ); lecture.setCapacityByGrade(request.capacityByGrade()); + lecture.updateSpeakers(resolveSpeakers(request.speakerIds())); lecture = lectureRepository.save(lecture); return toLectureDetail(lecture, userId); } @@ -86,7 +91,7 @@ public LectureDetailResponse createLecture(Long userId, LectureCreateRequest req public Page getLectures(Pageable pageable, Long viewerId) { Page lectures = viewerId == null ? lectureRepository.findAllByApprovalStatusOrderByCreatedAtDesc(ApprovalStatus.APPROVED, pageable) - : lectureRepository.findAllByApprovalStatusOrCreatorIdOrderByCreatedAtDesc(ApprovalStatus.APPROVED, viewerId, pageable); + : lectureRepository.findVisibleToUser(ApprovalStatus.APPROVED, viewerId, pageable); Map> enrollmentCountsByLectureId = getEnrollmentCountsByLectureIds(lectures.getContent()); return lectures.map(lecture -> toLectureSummary(lecture, enrollmentCountsByLectureId, viewerId)); @@ -125,6 +130,7 @@ public LectureDetailResponse getLectureDetailForDiscord(Long lectureId) { @Transactional public LectureDetailResponse updateLecture(Long lectureId, Long userId, Role userRole, LectureUpdateRequest request) { validateLectureCapacityRules(request.capacityByGrade(), request.totalCapacity()); + timeValidator.validateApplicationDeadline(request.lectureDate(), request.lectureTime(), request.applicationDeadline()); LectureEntity lecture = requireLecture(lectureId); validateCreator(lecture, userId, userRole); @@ -139,6 +145,7 @@ public LectureDetailResponse updateLecture(Long lectureId, Long userId, Role use request.lectureTime(), request.applicationDeadline() ); + lecture.updateSpeakers(resolveSpeakers(request.speakerIds())); return toLectureDetail(lecture, userId); } @@ -161,26 +168,28 @@ public EnrollmentResponse enroll(Long lectureId, Long userId) { LocalDateTime now = schoolTimeNow(); - LocalDateTime openTime = enrollmentOpenAt(lecture.getCreatedAt()); - if (openTime != null && now.isBefore(openTime)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "수강 신청은 " + openTime.toLocalDate() + " 오후 4시 20분부터 가능합니다."); - } + // approvedAt·createdAt은 서버가 UTC로 찍은 값이라 한국 시간 벽시계로 옮겨야 16:20이 맞는다. + LocalDateTime applicationOpenReference = toSchoolTime( + lecture.getApprovedAt() != null ? lecture.getApprovedAt() : lecture.getCreatedAt()); + timeValidator.validateApplicationTime(applicationOpenReference, lecture.getApplicationDeadline(), now); + boolean isAfterApplicationDeadline = isAfterApplicationDeadline(lecture, now); - refreshLectureLifecycle(lecture, now); + lifecycleHandler.refreshLectureLifecycle(lecture, now); if (lecture.getStatus() == LectureStatus.CLOSE) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "이미 종료된 강의입니다."); } - if (now.isAfter(lecture.getApplicationDeadline())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "신청 마감일이 지났습니다."); - } + UserEntity user = requireUser(userId); + if (lecture.isSpeaker(userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "연사자는 자신의 강연에 수강 신청할 수 없습니다."); + } lectureEnrollmentRepository.findByLectureIdAndUserId(lectureId, userId) .ifPresent(existing -> { throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 신청한 강의입니다."); }); - Integer userGrade = extractGradeFromStudentNumber(user.getStudentNumber()); + Integer userGrade = lifecycleHandler.extractGradeFromStudentNumber(user.getStudentNumber()); Map capacityByGrade = lecture.getCapacityByGrade() == null ? Map.of() : lecture.getCapacityByGrade(); Integer totalCapacity = lecture.getTotalCapacity(); long enrolledCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lectureId, EnrollmentStatus.ENROLLED); @@ -188,9 +197,9 @@ public EnrollmentResponse enroll(Long lectureId, Long userId) { boolean useGradeCapacity = !capacityByGrade.isEmpty(); boolean isFull; - if (useGradeCapacity) { - // 학년을 못 읽거나 배정이 없는 학년은 앉을 자리가 없으므로 대기로 받는다. - // 마감 뒤 자리가 남으면 그때 순번대로 올라온다. + if (isAfterApplicationDeadline) { + isFull = true; + } else if (useGradeCapacity) { Integer gradeCapacity = userGrade == null ? null : capacityByGrade.get(userGrade); if (gradeCapacity == null) { isFull = true; @@ -223,34 +232,14 @@ protected LocalDateTime schoolTimeNow() { return LocalDateTime.now(SCHOOL_ZONE); } - /** 서버가 UTC로 찍은 시각(createdAt)을 한국 시간 벽시계로 옮긴다. */ + /** 서버가 UTC로 찍은 시각(createdAt, approvedAt)을 한국 시간 벽시계로 옮긴다. */ private LocalDateTime toSchoolTime(LocalDateTime serverTime) { return serverTime.atOffset(ZoneOffset.UTC).atZoneSameInstant(SCHOOL_ZONE).toLocalDateTime(); } - /** - * 신청이 열리는 시각(한국 시간). 개설한 날 16:20이 기본이고, - * 개설 시점이 이미 그 시각을 넘겼으면 다음 날 16:20이다. - */ - private LocalDateTime enrollmentOpenAt(LocalDateTime createdAt) { - if (createdAt == null) { - return null; - } - - LocalDateTime created = toSchoolTime(createdAt); - LocalDateTime openAt = created.toLocalDate().atTime(ENROLLMENT_OPEN_TIME); - - return created.isBefore(openAt) ? openAt : openAt.plusDays(1); - } - - /** 학번 "2204"의 맨 앞자리가 학년이다. 두 번째 자리는 반이므로 읽으면 안 된다. */ - private Integer extractGradeFromStudentNumber(String studentNumber) { - if (studentNumber == null || studentNumber.isEmpty()) return null; - try { - return Integer.parseInt(studentNumber.substring(0, 1)); - } catch (Exception e) { - return null; - } + /** 마감 시각은 사용자가 한국 시간으로 넣은 값이 그대로 저장되므로 학교 시간끼리 비교한다. */ + private boolean isAfterApplicationDeadline(LectureEntity lecture, LocalDateTime now) { + return lecture.getApplicationDeadline() != null && now.isAfter(lecture.getApplicationDeadline()); } @Transactional @@ -258,27 +247,35 @@ public EnrollmentResponse cancelEnrollment(Long lectureId, Long userId) { LectureEntity lecture = requireLectureForUpdate(lectureId); LocalDateTime now = schoolTimeNow(); - // 마감이 지나면 명단이 확정된다. 이때 빠지면 남은 자리를 다시 채울 방법이 없다. - if (isAfterApplicationDeadline(lecture, now)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "신청 마감 후에는 취소할 수 없습니다."); - } - LectureEnrollmentEntity enrollment = lectureEnrollmentRepository.findByLectureIdAndUserId(lectureId, userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "신청 내역이 없습니다.")); EnrollmentStatus canceledStatus = enrollment.getStatus(); - lectureEnrollmentRepository.delete(enrollment); - if (canceledStatus == EnrollmentStatus.ENROLLED) { - promoteFirstWaitingUser(lecture, now); + // 마감이 지나면 확정된 명단은 잠근다. 빠진 자리를 다시 채울 방법이 없기 때문이다. + // 대기는 아직 자리를 차지한 게 아니라서 마감 뒤에도 스스로 미룰 수 있다. + if (canceledStatus == EnrollmentStatus.ENROLLED && isAfterApplicationDeadline(lecture, now)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "신청 마감 후에는 확정된 신청을 취소할 수 없습니다."); } + lectureEnrollmentRepository.delete(enrollment); + long enrolledCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lectureId, EnrollmentStatus.ENROLLED); long waitingCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lectureId, EnrollmentStatus.WAITING); return new EnrollmentResponse(lecture.getId(), "CANCELED", enrolledCount, waitingCount, null); } + @Scheduled(fixedDelayString = "${rels.lecture.lifecycle-sync-delay-ms:60000}") + @Transactional + public void syncLectureStatuses() { + LocalDateTime now = schoolTimeNow(); + List lectures = lectureRepository.findAll(); + for (LectureEntity lecture : lectures) { + lifecycleHandler.refreshLectureLifecycle(lecture, now); + } + } + private void validateLectureCapacityRules(Map capacityByGrade, Integer totalCapacity) { if (capacityByGrade != null && !capacityByGrade.isEmpty() && totalCapacity != null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "학년별 정원과 전체 정원은 동시에 설정할 수 없습니다."); @@ -288,22 +285,13 @@ private void validateLectureCapacityRules(Map capacityByGrade, if (totalCapacity < MIN_CAPACITY) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "전체 정원은 " + MIN_CAPACITY + "명 이상이어야 합니다."); } - if (totalCapacity > MAX_CAPACITY) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "전체 정원은 최대 " + MAX_CAPACITY + "명까지 설정할 수 있습니다."); - } } if (capacityByGrade != null && !capacityByGrade.isEmpty()) { - int gradeCapacitySum = capacityByGrade.values().stream() - .mapToInt(Integer::intValue) - .sum(); + int gradeCapacitySum = capacityByGrade.values().stream().mapToInt(Integer::intValue).sum(); if (gradeCapacitySum < MIN_CAPACITY) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "학년별 정원의 합계는 " + MIN_CAPACITY + "명 이상이어야 합니다."); } - if (gradeCapacitySum > MAX_CAPACITY) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "학년별 정원의 합계는 최대 " + MAX_CAPACITY + "명까지 설정할 수 있습니다."); - } - for (Map.Entry e : capacityByGrade.entrySet()) { Integer grade = e.getKey(); Integer cap = e.getValue(); @@ -313,62 +301,6 @@ private void validateLectureCapacityRules(Map capacityByGrade, if (cap < 0) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "학년별 정원은 0 이상이어야 합니다. (학년: " + grade + ")"); } - if (cap > MAX_CAPACITY) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "한 학년의 정원은 최대 " + MAX_CAPACITY + "명까지 설정할 수 있습니다. (학년: " + grade + ")"); - } - } - } - } - - /** - * 자리가 비면 대기자 한 명을 올린다. - * - * 신청을 받는 동안에는 자리가 남은 학년의 대기자만 올라올 수 있다. 맨 앞 대기자를 - * 그냥 올리면 1학년이 비운 자리를 2학년이 채워 학년 정원이 넘칠 수 있기 때문이다. - * 마감 뒤에는 학년 정원을 더 보지 않고 전체 정원까지 순번대로 올린다. - */ - private void promoteFirstWaitingUser(LectureEntity lecture, LocalDateTime now) { - Long lectureId = lecture.getId(); - List enrollments = lectureEnrollmentRepository.findAllByLectureId(lectureId); - - List enrolled = enrollments.stream() - .filter(e -> e.getStatus() == EnrollmentStatus.ENROLLED) - .toList(); - - int capacity = resolveTotalCapacity(lecture); - if (capacity > 0 && enrolled.size() >= capacity) { - return; - } - - List waiting = sortByRequestedOrder(enrollments.stream() - .filter(e -> e.getStatus() == EnrollmentStatus.WAITING) - .toList()); - if (waiting.isEmpty()) { - return; - } - - Map capacityByGrade = lecture.getCapacityByGrade(); - boolean useGradeCapacity = capacityByGrade != null && !capacityByGrade.isEmpty() - && !isAfterApplicationDeadline(lecture, now); - - if (!useGradeCapacity) { - waiting.get(0).promoteToEnrolled(); - return; - } - - for (LectureEnrollmentEntity candidate : waiting) { - Integer grade = extractGradeFromStudentNumber(candidate.getUser().getStudentNumber()); - Integer gradeCapacity = grade == null ? null : capacityByGrade.get(grade); - if (gradeCapacity == null) { - continue; - } - - long taken = enrolled.stream() - .filter(e -> grade.equals(extractGradeFromStudentNumber(e.getUser().getStudentNumber()))) - .count(); - if (taken < gradeCapacity) { - candidate.promoteToEnrolled(); - return; } } } @@ -376,25 +308,10 @@ private void promoteFirstWaitingUser(LectureEntity lecture, LocalDateTime now) { private long countEnrolledInGrade(Long lectureId, Integer grade) { return lectureEnrollmentRepository.findAllByLectureId(lectureId).stream() .filter(e -> e.getStatus() == EnrollmentStatus.ENROLLED) - .filter(e -> grade.equals(extractGradeFromStudentNumber(e.getUser().getStudentNumber()))) + .filter(e -> grade.equals(lifecycleHandler.extractGradeFromStudentNumber(e.getUser().getStudentNumber()))) .count(); } - /** 신청 순서. 신청 시각이 같으면 먼저 저장된 쪽이 앞선다. */ - private List sortByRequestedOrder(List enrollments) { - return enrollments.stream() - .sorted(Comparator - .comparing(LectureEnrollmentEntity::getRequestedAt, - Comparator.nullsLast(Comparator.naturalOrder())) - .thenComparing(LectureEnrollmentEntity::getId, - Comparator.nullsLast(Comparator.naturalOrder()))) - .toList(); - } - - private boolean isAfterApplicationDeadline(LectureEntity lecture, LocalDateTime now) { - return lecture.getApplicationDeadline() != null && now.isAfter(lecture.getApplicationDeadline()); - } - private LectureSummaryResponse toLectureSummary(LectureEntity lecture, Map> enrollmentCountsByLectureId, Long viewerId) { @@ -402,7 +319,7 @@ private LectureSummaryResponse toLectureSummary(LectureEntity lecture, throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "강의 생성자 정보가 없습니다."); } long enrolledCount = getEnrollmentCount(enrollmentCountsByLectureId, lecture.getId(), EnrollmentStatus.ENROLLED); - refreshLectureLifecycle(lecture, schoolTimeNow(), enrolledCount); + lifecycleHandler.refreshLectureLifecycle(lecture, schoolTimeNow(), enrolledCount); long waitingCount = getEnrollmentCount(enrollmentCountsByLectureId, lecture.getId(), EnrollmentStatus.WAITING); return new LectureSummaryResponse( @@ -412,6 +329,7 @@ private LectureSummaryResponse toLectureSummary(LectureEntity lecture, lecture.getCreator().getId(), lecture.getCreator().getName(), lecture.getCreator().getStudentNumber(), + toSpeakerResponses(lecture), lecture.getStatus().name(), lecture.getApprovalStatus().name(), resolveRejectionReason(lecture, viewerId), @@ -422,19 +340,16 @@ private LectureSummaryResponse toLectureSummary(LectureEntity lecture, lecture.getLectureTime(), lecture.getApplicationDeadline(), lecture.getCreatedAt(), + lecture.getApprovedAt(), lecture.getCapacityByGrade(), lecture.getTotalCapacity() ); } private Map> getEnrollmentCountsByLectureIds(List lectures) { - if (lectures.isEmpty()) { - return Map.of(); - } + if (lectures.isEmpty()) return Map.of(); - List lectureIds = lectures.stream() - .map(LectureEntity::getId) - .toList(); + List lectureIds = lectures.stream().map(LectureEntity::getId).toList(); return lectureEnrollmentRepository.countEnrollmentsByLectureIds(lectureIds).stream() .collect(Collectors.groupingBy( @@ -454,7 +369,7 @@ private LectureDetailResponse toLectureDetail(LectureEntity lecture, Long userId if (lecture.getCreator() == null) { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "강의 생성자 정보가 없습니다."); } - refreshLectureLifecycle(lecture, schoolTimeNow()); + lifecycleHandler.refreshLectureLifecycle(lecture, schoolTimeNow()); long enrolledCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lecture.getId(), EnrollmentStatus.ENROLLED); long waitingCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lecture.getId(), EnrollmentStatus.WAITING); @@ -469,6 +384,7 @@ private LectureDetailResponse toLectureDetail(LectureEntity lecture, Long userId lecture.getCreator().getId(), lecture.getCreator().getName(), lecture.getCreator().getStudentNumber(), + toSpeakerResponses(lecture), lecture.getStatus().name(), lecture.getApprovalStatus().name(), resolveRejectionReason(lecture, userId), @@ -480,21 +396,16 @@ private LectureDetailResponse toLectureDetail(LectureEntity lecture, Long userId lecture.getLectureTime(), lecture.getApplicationDeadline(), lecture.getCreatedAt(), + lecture.getApprovedAt(), lecture.getCapacityByGrade(), lecture.getTotalCapacity() ); } private void validateApprovalVisibility(LectureEntity lecture, Long viewerId, Role viewerRole) { - if (lecture.getApprovalStatus() == ApprovalStatus.APPROVED) { - return; - } - if (viewerRole == Role.ADMIN) { - return; - } - if (isCreator(lecture, viewerId)) { - return; - } + if (lecture.getApprovalStatus() == ApprovalStatus.APPROVED) return; + if (viewerRole == Role.ADMIN) return; + if (isCreator(lecture, viewerId) || lecture.isSpeaker(viewerId)) return; throw new ResponseStatusException(HttpStatus.FORBIDDEN, "아직 승인되지 않은 강연입니다."); } @@ -504,10 +415,8 @@ private boolean isCreator(LectureEntity lecture, Long viewerId) { } private String resolveRejectionReason(LectureEntity lecture, Long viewerId) { - if (lecture.getApprovalStatus() != ApprovalStatus.REJECTED) { - return null; - } - return isCreator(lecture, viewerId) ? lecture.getRejectionReason() : null; + if (lecture.getApprovalStatus() != ApprovalStatus.REJECTED) return null; + return (isCreator(lecture, viewerId) || lecture.isSpeaker(viewerId)) ? lecture.getRejectionReason() : null; } private UserEntity requireUser(Long userId) { @@ -515,6 +424,23 @@ private UserEntity requireUser(Long userId) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "사용자를 찾을 수 없습니다.")); } + private Set resolveSpeakers(Set speakerIds) { + if (speakerIds == null || speakerIds.isEmpty()) { + return Set.of(); + } + List speakers = userRepository.findAllById(speakerIds); + if (speakers.size() != speakerIds.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "등록되지 않은 연사자가 포함되어 있습니다."); + } + return Set.copyOf(speakers); + } + + private List toSpeakerResponses(LectureEntity lecture) { + return lecture.getSpeakers().stream() + .map(speaker -> new LectureSpeakerResponse(speaker.getId(), speaker.getName(), speaker.getStudentNumber())) + .toList(); + } + private LectureEntity requireLecture(Long lectureId) { return lectureRepository.findById(lectureId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "강의를 찾을 수 없습니다.")); @@ -527,21 +453,11 @@ private LectureEntity requireLectureForUpdate(Long lectureId) { private void validateCreator(LectureEntity lecture, Long userId, Role userRole) { if (lecture.getCreator() == null) { - throw new ResponseStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, - "강의 생성자 정보가 없습니다." - ); - } - - if (userRole == Role.ADMIN) { - return; + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "강의 생성자 정보가 없습니다."); } - + if (userRole == Role.ADMIN) return; if (!lecture.getCreator().getId().equals(userId)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "강의 작성자만 수정 또는 삭제할 수 있습니다." - ); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "강의 작성자만 수정 또는 삭제할 수 있습니다."); } } @@ -551,130 +467,53 @@ private void validateAdmin(Role userRole) { } } - @Scheduled(fixedDelayString = "${rels.lecture.lifecycle-sync-delay-ms:60000}") - @Transactional - public void syncLectureStatuses() { - syncLectureStatuses(schoolTimeNow()); - } - - private void syncLectureStatuses(LocalDateTime now) { - List lectures = lectureRepository.findAll(); - for (LectureEntity lecture : lectures) { - promoteWaitingAfterDeadline(lecture, now); - refreshLectureLifecycle(lecture, now); - } - } - /** - * 신청 마감이 지나면 비어 있는 자리를 대기자로 채운다. - * 학년별 정원은 신청을 받는 동안만 적용하고, 마감 뒤에는 전체 정원까지 신청 순서대로 올린다. + * 신청자·대기자 명단은 누가 신청했는지 보고 판단하는 정보라 학생 누구나 볼 수 있다. + * 다만 누가 거절됐는지는 명단에 뿌릴 정보가 아니라서 개설자와 학생회에게만 내려준다. */ - private void promoteWaitingAfterDeadline(LectureEntity lecture, LocalDateTime now) { - if (lecture.getId() == null || lecture.getStatus() == LectureStatus.CLOSE) { - return; - } - - if (!isAfterApplicationDeadline(lecture, now)) { - return; - } - - int capacity = resolveTotalCapacity(lecture); - if (capacity <= 0) { - return; - } - - List enrollments = lectureEnrollmentRepository.findAllByLectureId(lecture.getId()); - long enrolledCount = enrollments.stream() - .filter(e -> e.getStatus() == EnrollmentStatus.ENROLLED) - .count(); - if (enrolledCount >= capacity) { - return; - } - - List waiting = sortByRequestedOrder(enrollments.stream() - .filter(e -> e.getStatus() == EnrollmentStatus.WAITING) - .toList()); - - for (LectureEnrollmentEntity enrollment : waiting) { - if (enrolledCount >= capacity) { - break; - } - enrollment.promoteToEnrolled(); - enrolledCount++; - } - } - - /** 전체 정원. 학년별로 나눈 강연은 학년 정원의 합이 전체 정원이 된다. */ - private int resolveTotalCapacity(LectureEntity lecture) { - if (lecture.getTotalCapacity() != null) { - return lecture.getTotalCapacity(); - } + @Transactional(readOnly = true) + public EnrollmentListResponse getEnrollments(Long lectureId, Long currentUserId, Role currentUserRole) { + LectureEntity lecture = requireLecture(lectureId); + validateApprovalVisibility(lecture, currentUserId, currentUserRole); + List allEnrollments = lectureEnrollmentRepository.findAllByLectureId(lectureId); - Map capacityByGrade = lecture.getCapacityByGrade(); - if (capacityByGrade == null || capacityByGrade.isEmpty()) { - return 0; - } + List enrolled = filterEnrollmentsByStatus(allEnrollments, EnrollmentStatus.ENROLLED); + List waiting = filterEnrollmentsByStatus(allEnrollments, EnrollmentStatus.WAITING); + List rejected = canManageEnrollments(lecture, currentUserId, currentUserRole) + ? filterEnrollmentsByStatus(allEnrollments, EnrollmentStatus.REJECTED) + : List.of(); - return capacityByGrade.values().stream() - .filter(Objects::nonNull) - .mapToInt(Integer::intValue) - .sum(); + return new EnrollmentListResponse(enrolled, waiting, rejected); } - private void refreshLectureLifecycle(LectureEntity lecture, LocalDateTime now) { - if (lecture.getId() == null) { - LocalDateTime lectureEndDateTime = lecture.getLectureEndDateTime(); - if (lecture.getStatus() != LectureStatus.CLOSE && lectureEndDateTime != null && now.isAfter(lectureEndDateTime)) { - lecture.close(); - } - return; - } - - long enrolledCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lecture.getId(), EnrollmentStatus.ENROLLED); - refreshLectureLifecycle(lecture, now, enrolledCount); + private List filterEnrollmentsByStatus(List enrollments, + EnrollmentStatus status) { + return enrollments.stream() + .filter(e -> e.getStatus() == status) + .map(this::toEnrollmentUserResponse) + .toList(); } - private void refreshLectureLifecycle(LectureEntity lecture, LocalDateTime now, long enrolledCount) { - if (lecture.getStatus() == LectureStatus.CLOSE) { - return; - } - - LocalDateTime lectureEndDateTime = lecture.getLectureEndDateTime(); - if (lectureEndDateTime != null && now.isAfter(lectureEndDateTime)) { - lecture.close(); - return; - } - - if (lecture.getStatus() != LectureStatus.OPEN) { - return; + @Transactional + public EnrollmentResponse decideWaitingEnrollment(Long lectureId, Long enrollmentUserId, Long currentUserId, + Role currentUserRole, EnrollmentDecisionRequest request) { + LectureEntity lecture = requireLecture(lectureId); + validateCreatorOrAdmin(lecture, currentUserId, currentUserRole); + LectureEnrollmentEntity enrollment = lectureEnrollmentRepository.findByLectureIdAndUserId(lectureId, enrollmentUserId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "대기 신청 내역이 없습니다.")); + if (enrollment.getStatus() != EnrollmentStatus.WAITING) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "대기 상태의 신청만 수락 또는 거절할 수 있습니다."); } - if (lecture.getApplicationDeadline() != null && now.isAfter(lecture.getApplicationDeadline())) { - if (enrolledCount >= CONFIRM_THRESHOLD) { - lecture.confirm(); - return; - } - lecture.setStatus(LectureStatus.UNCONFIRMED); + long enrolledCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lectureId, EnrollmentStatus.ENROLLED); + long waitingCount = lectureEnrollmentRepository.countByLectureIdAndStatus(lectureId, EnrollmentStatus.WAITING); + if (request.approved()) { + enrollment.promoteToEnrolled(); + return new EnrollmentResponse(lectureId, EnrollmentStatus.ENROLLED.name(), enrolledCount + 1, waitingCount - 1, enrollment.getRequestedAt()); } - } - - @Transactional(readOnly = true) - public EnrollmentListResponse getEnrollments(Long lectureId) { - requireLecture(lectureId); - - List allEnrollments = lectureEnrollmentRepository.findAllByLectureId(lectureId); - - List enrolled = allEnrollments.stream() - .filter(e -> e.getStatus() == EnrollmentStatus.ENROLLED) - .map(this::toEnrollmentUserResponse) - .toList(); - List waiting = allEnrollments.stream() - .filter(e -> e.getStatus() == EnrollmentStatus.WAITING) - .map(this::toEnrollmentUserResponse) - .toList(); - - return new EnrollmentListResponse(enrolled, waiting); + enrollment.reject(); + return new EnrollmentResponse(lectureId, EnrollmentStatus.REJECTED.name(), enrolledCount, waitingCount - 1, enrollment.getRequestedAt()); } private EnrollmentUserResponse toEnrollmentUserResponse(LectureEnrollmentEntity enrollment) { @@ -713,11 +552,12 @@ public MyLecturesResponse getMyLectures(Long userId) { }) .toList(); - List myLectures = lectureRepository.findAllByCreatorIdOrderByCreatedAtDesc(userId); + List myLectures = lectureRepository.findAllBySpeakerIdOrderByCreatedAtDesc(userId); List createdLectures = myLectures.stream() .map(lecture -> new MyCreatedLectureResponse( lecture.getId(), lecture.getTitle(), + isCreator(lecture, userId), lecture.getStatus().name(), lecture.getApprovalStatus().name(), lecture.getRejectionReason(), @@ -767,11 +607,14 @@ public void updateAttendances(Long lectureId, Long currentUserId, Role currentUs } private void validateCreatorOrAdmin(LectureEntity lecture, Long userId, Role userRole) { - if (userRole == Role.ADMIN) { - return; - } - if (lecture.getCreator() == null || !lecture.getCreator().getId().equals(userId)) { + if (!canManageEnrollments(lecture, userId, userRole)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "강의 작성자 또는 관리자만 접근 가능합니다."); } } -} \ No newline at end of file + + /** 대기자를 수락·거절하고 거절 명단까지 볼 수 있는 사람인지. */ + private boolean canManageEnrollments(LectureEntity lecture, Long userId, Role userRole) { + if (userRole == Role.ADMIN) return true; + return isCreator(lecture, userId); + } +} diff --git a/src/main/java/com/example/rels/domain/lecture/service/LectureTimeValidator.java b/src/main/java/com/example/rels/domain/lecture/service/LectureTimeValidator.java new file mode 100644 index 0000000..a8218f7 --- /dev/null +++ b/src/main/java/com/example/rels/domain/lecture/service/LectureTimeValidator.java @@ -0,0 +1,49 @@ +package com.example.rels.domain.lecture.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class LectureTimeValidator { + + public void validateApplicationDeadline(LocalDate lectureDate, LocalTime lectureTime, LocalDateTime applicationDeadline) { + if (lectureDate == null || lectureTime == null || applicationDeadline == null) { + return; + } + + LocalDateTime lectureStartDateTime = LocalDateTime.of(lectureDate, lectureTime); + + if (applicationDeadline.isAfter(lectureStartDateTime) || applicationDeadline.isEqual(lectureStartDateTime)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "신청 마감 시간은 강연 시작 일시보다 이전이어야 합니다." + ); + } + } + + public void validateApplicationTime(LocalDateTime approvalTime, LocalDateTime deadline, LocalDateTime now) { + LocalDateTime openTime = approvalTime.toLocalDate().atTime(16, 20); + if (approvalTime.isAfter(openTime)) { + openTime = openTime.plusDays(1); + } + + if (now.isBefore(openTime)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "수강 신청은 " + openTime.toLocalDate() + " 오후 4시 20분부터 가능합니다." + ); + } + + if (deadline != null && now.isAfter(deadline)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "수강 신청 마감 시간이 지났습니다." + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/rels/domain/user/controller/UserController.java b/src/main/java/com/example/rels/domain/user/controller/UserController.java new file mode 100644 index 0000000..2d4bc79 --- /dev/null +++ b/src/main/java/com/example/rels/domain/user/controller/UserController.java @@ -0,0 +1,29 @@ +package com.example.rels.domain.user.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.rels.domain.user.dto.UserSummaryResponse; +import com.example.rels.domain.user.service.UserService; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + /** 연사자 선택용 사용자 검색. 이름 또는 학번 일부로 찾는다. */ + @GetMapping + public List searchUsers( + @RequestParam(name = "keyword", required = false) String keyword) { + return userService.searchUsers(keyword); + } +} diff --git a/src/main/java/com/example/rels/domain/user/dto/UserSummaryResponse.java b/src/main/java/com/example/rels/domain/user/dto/UserSummaryResponse.java new file mode 100644 index 0000000..24840e2 --- /dev/null +++ b/src/main/java/com/example/rels/domain/user/dto/UserSummaryResponse.java @@ -0,0 +1,8 @@ +package com.example.rels.domain.user.dto; + +public record UserSummaryResponse( + Long userId, + String name, + String studentNumber +) { +} diff --git a/src/main/java/com/example/rels/domain/user/repository/UserRepository.java b/src/main/java/com/example/rels/domain/user/repository/UserRepository.java index ae6ec9b..917831e 100644 --- a/src/main/java/com/example/rels/domain/user/repository/UserRepository.java +++ b/src/main/java/com/example/rels/domain/user/repository/UserRepository.java @@ -1,5 +1,6 @@ package com.example.rels.domain.user.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,5 +10,9 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + + /** 연사자 검색. 이름 일부 또는 학번 일부로 찾고, 학번 순으로 20명까지 준다. */ + List findTop20ByNameContainingIgnoreCaseOrStudentNumberContainingOrderByStudentNumberAsc( + String name, String studentNumber); } diff --git a/src/main/java/com/example/rels/domain/user/service/UserService.java b/src/main/java/com/example/rels/domain/user/service/UserService.java new file mode 100644 index 0000000..8258d4a --- /dev/null +++ b/src/main/java/com/example/rels/domain/user/service/UserService.java @@ -0,0 +1,38 @@ +package com.example.rels.domain.user.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.rels.domain.user.dto.UserSummaryResponse; +import com.example.rels.domain.user.repository.UserRepository; + +@Service +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * 연사자를 고르려면 이름이나 학번으로 사람을 찾아야 한다. + * 검색어가 비면 전교생 명부가 그대로 흘러나오므로 빈 목록을 돌려준다. + */ + @Transactional(readOnly = true) + public List searchUsers(String keyword) { + if (keyword == null || keyword.isBlank()) { + return List.of(); + } + + String trimmed = keyword.trim(); + + return userRepository + .findTop20ByNameContainingIgnoreCaseOrStudentNumberContainingOrderByStudentNumberAsc(trimmed, trimmed) + .stream() + .map(user -> new UserSummaryResponse(user.getId(), user.getName(), user.getStudentNumber())) + .toList(); + } +} diff --git a/src/test/java/com/example/rels/lecture/service/LectureEnrollmentServiceTest.java b/src/test/java/com/example/rels/lecture/service/LectureEnrollmentServiceTest.java new file mode 100644 index 0000000..2e91f40 --- /dev/null +++ b/src/test/java/com/example/rels/lecture/service/LectureEnrollmentServiceTest.java @@ -0,0 +1,367 @@ +package com.example.rels.lecture.service; + +import com.example.rels.domain.lecture.dto.request.EnrollmentDecisionRequest; +import com.example.rels.domain.lecture.dto.response.EnrollmentListResponse; +import com.example.rels.domain.lecture.dto.response.EnrollmentResponse; +import com.example.rels.domain.lecture.entity.EnrollmentStatus; +import com.example.rels.domain.lecture.entity.LectureEnrollmentEntity; +import com.example.rels.domain.lecture.entity.LectureEntity; +import com.example.rels.domain.lecture.repository.LectureEnrollmentRepository; +import com.example.rels.domain.lecture.repository.LectureRepository; +import com.example.rels.domain.lecture.service.LectureLifecycleHandler; +import com.example.rels.domain.lecture.service.LectureService; +import com.example.rels.domain.lecture.service.LectureTimeValidator; +import com.example.rels.domain.user.entity.Role; +import com.example.rels.domain.user.entity.UserEntity; +import com.example.rels.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LectureEnrollmentServiceTest { + + @Mock + private LectureRepository lectureRepository; + + @Mock + private LectureEnrollmentRepository lectureEnrollmentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private LectureTimeValidator timeValidator; + + private LectureLifecycleHandler lifecycleHandler; + + private LectureService lectureService; + + @BeforeEach + void setUp() { + lifecycleHandler = new LectureLifecycleHandler(lectureEnrollmentRepository); + lectureService = new LectureService( + lectureRepository, + lectureEnrollmentRepository, + userRepository, + timeValidator, + lifecycleHandler + ); + + LectureEnrollmentEntity savedMock = mock(LectureEnrollmentEntity.class); + lenient().when(savedMock.getRequestedAt()).thenReturn(LocalDateTime.now()); + lenient().when(lectureEnrollmentRepository.save(any(LectureEnrollmentEntity.class))).thenReturn(savedMock); + } + + @Test + void enrollRejectsEndedLecture() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().minusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), null, 1L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + doThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "강의가 종료되었습니다.")) + .when(timeValidator).validateApplicationTime(any(), any(), any()); + + var exception = assertThrows(ResponseStatusException.class, () -> lectureService.enroll(1L, 2L)); + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + } + + @Test + void enrollRejectsSpeaker() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + UserEntity speaker = TestEntityFactory.createUser("speaker@test.com", "speaker", "1000000001", Role.USER, 2L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().plusDays(1), 30, 1L); + lecture.updateSpeakers(java.util.Set.of(speaker)); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(userRepository.findById(2L)).thenReturn(Optional.of(speaker)); + + var exception = assertThrows(ResponseStatusException.class, () -> lectureService.enroll(1L, 2L)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + verify(lectureEnrollmentRepository, never()).save(any()); + } + + @Test + @DisplayName("수강 신청 오픈 시간 전 신청 시 예외가 발생한다") + void enrollRejectsBeforeOpenTime() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().plusDays(2), 30, 1L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + doThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "오후 4시 20분부터 가능합니다.")) + .when(timeValidator).validateApplicationTime(any(), any(), any()); + + var exception = assertThrows(ResponseStatusException.class, () -> lectureService.enroll(1L, 2L)); + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertNotNull(exception.getReason()); + assertTrue(exception.getReason().contains("오후 4시 20분부터 가능합니다.")); + } + + @Test + void enrollConfirmsLectureAtThreshold() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 30, 1L); + UserEntity applicant = TestEntityFactory.createUser("user@test.com", "user", "1000000001", Role.USER, 2L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(9L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); + + EnrollmentResponse response = lectureService.enroll(1L, 2L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LectureEnrollmentEntity.class); + verify(lectureEnrollmentRepository).save(captor.capture()); + LectureEnrollmentEntity saved = captor.getValue(); + + assertSame(lecture, saved.getLecture()); + assertSame(applicant, saved.getUser()); + assertEquals(EnrollmentStatus.ENROLLED, saved.getStatus()); + assertEquals("ENROLLED", response.enrollmentStatus()); + } + + @Test + void enrollMovesToWaitingAfterCapacity() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 30, 1L); + UserEntity applicant = TestEntityFactory.createUser("user@test.com", "user", "1000000001", Role.USER, 2L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(30L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(4L); + + EnrollmentResponse response = lectureService.enroll(1L, 2L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LectureEnrollmentEntity.class); + verify(lectureEnrollmentRepository).save(captor.capture()); + LectureEnrollmentEntity saved = captor.getValue(); + + assertEquals(EnrollmentStatus.WAITING, saved.getStatus()); + assertEquals("WAITING", response.enrollmentStatus()); + } + + @Test + void enrollAfterApplicationDeadlineAddsUserToWaitingList() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + UserEntity applicant = TestEntityFactory.createUser("user@test.com", "user", "1000000001", Role.USER, 2L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().minusMinutes(1), 30, 1L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(0L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); + + EnrollmentResponse response = lectureService.enroll(1L, 2L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LectureEnrollmentEntity.class); + verify(lectureEnrollmentRepository).save(captor.capture()); + assertEquals(EnrollmentStatus.WAITING, captor.getValue().getStatus()); + assertEquals("WAITING", response.enrollmentStatus()); + } + + @Test + void enrollReadsGradeFromFirstDigitOfStudentNumber() { + LectureEntity lecture = TestEntityFactory.createGradeCapacityLecture(Map.of(1, 1, 2, 1, 3, 1), LocalDateTime.now().plusDays(1), 3); + UserEntity secondGrade = TestEntityFactory.createUser("second@test.com", "second", "2204", Role.USER, 1L); + UserEntity applicant = TestEntityFactory.createUser("third@test.com", "third", "3204", Role.USER, 2L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(1L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); + when(lectureEnrollmentRepository.findAllByLectureId(1L)) + .thenReturn(List.of(TestEntityFactory.createEnrollment(lecture, secondGrade, EnrollmentStatus.ENROLLED, 1L))); + + EnrollmentResponse response = lectureService.enroll(1L, 2L); + + assertEquals("ENROLLED", response.enrollmentStatus()); + } + + @Test + void cancelDoesNotAutomaticallyPromoteWaitingUser() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now(), LocalTime.NOON, LocalDateTime.now().plusDays(1), 30, 1L); + UserEntity applicant = TestEntityFactory.createUser("user@test.com", "user", "1000000001", Role.USER, 2L); + UserEntity waitingUser = TestEntityFactory.createUser("wait@test.com", "wait", "1000000002", Role.USER, 3L); + + LectureEnrollmentEntity enrolled = TestEntityFactory.createEnrollment(lecture, applicant, EnrollmentStatus.ENROLLED, 1L); + LectureEnrollmentEntity waiting = TestEntityFactory.createEnrollment(lecture, waitingUser, EnrollmentStatus.WAITING, 2L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(enrolled)); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(29L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(1L); + + EnrollmentResponse response = lectureService.cancelEnrollment(1L, 2L); + + verify(lectureEnrollmentRepository).delete(enrolled); + assertEquals(EnrollmentStatus.WAITING, waiting.getStatus()); + assertEquals("CANCELED", response.enrollmentStatus()); + } + + @Test + void cancelDoesNotAutomaticallyPromoteWaitingUserOfFreedUpGrade() { + LectureEntity lecture = TestEntityFactory.createGradeCapacityLecture(Map.of(1, 1, 2, 1), LocalDateTime.now().plusDays(1), 2); + UserEntity firstGrade = TestEntityFactory.createUser("first@test.com", "first", "1101", Role.USER, 1L); + UserEntity secondGrade = TestEntityFactory.createUser("second@test.com", "second", "2101", Role.USER, 2L); + + LectureEnrollmentEntity canceled = TestEntityFactory.createEnrollment(lecture, firstGrade, EnrollmentStatus.ENROLLED, 1L); + LectureEnrollmentEntity stillEnrolled = TestEntityFactory.createEnrollment(lecture, secondGrade, EnrollmentStatus.ENROLLED, 2L); + LectureEnrollmentEntity waitingSecondGrade = TestEntityFactory.createEnrollment(lecture, TestEntityFactory.createUser("second2@test.com", "second2", "2102", Role.USER, 3L), EnrollmentStatus.WAITING, 3L); + LectureEnrollmentEntity waitingFirstGrade = TestEntityFactory.createEnrollment(lecture, TestEntityFactory.createUser("first2@test.com", "first2", "1102", Role.USER, 4L), EnrollmentStatus.WAITING, 4L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(canceled)); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(1L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(1L); + + lectureService.cancelEnrollment(1L, 2L); + + assertEquals(EnrollmentStatus.WAITING, waitingSecondGrade.getStatus()); + assertEquals(EnrollmentStatus.WAITING, waitingFirstGrade.getStatus()); + } + + @Test + void syncDoesNotAutomaticallyPromoteWaitingUsersAfterDeadline() { + LectureEntity lecture = TestEntityFactory.createGradeCapacityLecture(Map.of(1, 1, 2, 1, 3, 1), LocalDateTime.now().minusHours(1), 3); + UserEntity firstGrade = TestEntityFactory.createUser("first@test.com", "first", "1101", Role.USER, 1L); + + LectureEnrollmentEntity enrolled = TestEntityFactory.createEnrollment(lecture, firstGrade, EnrollmentStatus.ENROLLED, 1L); + LectureEnrollmentEntity firstWaiting = TestEntityFactory.createEnrollment(lecture, TestEntityFactory.createUser("second@test.com", "second", "2101", Role.USER, 2L), EnrollmentStatus.WAITING, 2L); + LectureEnrollmentEntity secondWaiting = TestEntityFactory.createEnrollment(lecture, TestEntityFactory.createUser("second2@test.com", "second2", "2102", Role.USER, 3L), EnrollmentStatus.WAITING, 3L); + LectureEnrollmentEntity thirdWaiting = TestEntityFactory.createEnrollment(lecture, TestEntityFactory.createUser("second3@test.com", "second3", "2103", Role.USER, 4L), EnrollmentStatus.WAITING, 4L); + + when(lectureRepository.findAll()).thenReturn(List.of(lecture)); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(1L); + + lectureService.syncLectureStatuses(); + + assertEquals(EnrollmentStatus.WAITING, firstWaiting.getStatus()); + assertEquals(EnrollmentStatus.WAITING, secondWaiting.getStatus()); + assertEquals(EnrollmentStatus.WAITING, thirdWaiting.getStatus()); + } + + @Test + void creatorCanAcceptWaitingUserEvenWhenCapacityIsFull() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + UserEntity waitingUser = TestEntityFactory.createUser("wait@test.com", "wait", "1000000001", Role.USER, 2L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().minusMinutes(1), 10, 1L); + LectureEnrollmentEntity waiting = TestEntityFactory.createEnrollment(lecture, waitingUser, EnrollmentStatus.WAITING, 2L); + + when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(waiting)); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(10L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(1L); + + EnrollmentResponse response = lectureService.decideWaitingEnrollment(1L, 2L, 1L, Role.USER, new EnrollmentDecisionRequest(true)); + + assertEquals(EnrollmentStatus.ENROLLED, waiting.getStatus()); + assertEquals("ENROLLED", response.enrollmentStatus()); + assertEquals(11L, response.enrolledCount()); + assertEquals(0L, response.waitingCount()); + } + + @Test + void creatorCanRejectWaitingUser() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + UserEntity waitingUser = TestEntityFactory.createUser("wait@test.com", "wait", "1000000001", Role.USER, 2L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().minusMinutes(1), 10, 1L); + LectureEnrollmentEntity waiting = TestEntityFactory.createEnrollment(lecture, waitingUser, EnrollmentStatus.WAITING, 2L); + + when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(waiting)); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(10L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(1L); + + EnrollmentResponse response = lectureService.decideWaitingEnrollment(1L, 2L, 1L, Role.USER, new EnrollmentDecisionRequest(false)); + + assertEquals(EnrollmentStatus.REJECTED, waiting.getStatus()); + assertEquals("REJECTED", response.enrollmentStatus()); + assertEquals(10L, response.enrolledCount()); + assertEquals(0L, response.waitingCount()); + } + + @Test + void getEnrollmentsLetsAnyStudentSeeRosterButHidesRejectedList() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + UserEntity enrolledUser = TestEntityFactory.createUser("a@test.com", "a", "1000000001", Role.USER, 2L); + UserEntity waitingUser = TestEntityFactory.createUser("b@test.com", "b", "1000000002", Role.USER, 3L); + UserEntity rejectedUser = TestEntityFactory.createUser("c@test.com", "c", "1000000003", Role.USER, 4L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().plusDays(1), 10, 1L); + + when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findAllByLectureId(1L)).thenReturn(List.of( + TestEntityFactory.createEnrollment(lecture, enrolledUser, EnrollmentStatus.ENROLLED, 1L), + TestEntityFactory.createEnrollment(lecture, waitingUser, EnrollmentStatus.WAITING, 2L), + TestEntityFactory.createEnrollment(lecture, rejectedUser, EnrollmentStatus.REJECTED, 3L))); + + EnrollmentListResponse asOtherStudent = lectureService.getEnrollments(1L, 9L, Role.USER); + + assertEquals(1, asOtherStudent.enrolled().size()); + assertEquals(1, asOtherStudent.waiting().size()); + assertTrue(asOtherStudent.rejected().isEmpty()); + + EnrollmentListResponse asCreator = lectureService.getEnrollments(1L, 1L, Role.USER); + + assertEquals(1, asCreator.rejected().size()); + assertEquals(4L, asCreator.rejected().get(0).userId()); + } + + @Test + void cancelRejectsConfirmedEnrollmentAfterDeadline() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().minusHours(1), 30, 1L); + UserEntity applicant = TestEntityFactory.createUser("user@test.com", "user", "1000000001", Role.USER, 2L); + LectureEnrollmentEntity enrolled = TestEntityFactory.createEnrollment(lecture, applicant, EnrollmentStatus.ENROLLED, 1L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(enrolled)); + + var exception = assertThrows(ResponseStatusException.class, + () -> lectureService.cancelEnrollment(1L, 2L)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + verify(lectureEnrollmentRepository, never()).delete(enrolled); + } + + @Test + void cancelAllowsWaitingEnrollmentAfterDeadline() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().minusHours(1), 30, 1L); + UserEntity applicant = TestEntityFactory.createUser("user@test.com", "user", "1000000001", Role.USER, 2L); + LectureEnrollmentEntity waiting = TestEntityFactory.createEnrollment(lecture, applicant, EnrollmentStatus.WAITING, 1L); + + when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(waiting)); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(30L); + when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); + + EnrollmentResponse response = lectureService.cancelEnrollment(1L, 2L); + + verify(lectureEnrollmentRepository).delete(waiting); + assertEquals("CANCELED", response.enrollmentStatus()); + } +} diff --git a/src/test/java/com/example/rels/lecture/service/LectureServiceTest.java b/src/test/java/com/example/rels/lecture/service/LectureServiceTest.java index afe8571..3c0be40 100644 --- a/src/test/java/com/example/rels/lecture/service/LectureServiceTest.java +++ b/src/test/java/com/example/rels/lecture/service/LectureServiceTest.java @@ -1,55 +1,48 @@ package com.example.rels.lecture.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.lang.reflect.Field; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; - +import com.example.rels.domain.lecture.service.LectureLifecycleHandler; +import com.example.rels.domain.lecture.service.LectureService; +import com.example.rels.domain.lecture.service.LectureTimeValidator; +import com.example.rels.domain.user.entity.Role; import com.example.rels.domain.lecture.dto.request.LectureCreateRequest; import com.example.rels.domain.lecture.dto.request.LectureUpdateRequest; -import com.example.rels.domain.lecture.dto.response.EnrollmentResponse; import com.example.rels.domain.lecture.dto.response.LectureDetailResponse; import com.example.rels.domain.lecture.dto.response.LectureSummaryResponse; import com.example.rels.domain.lecture.entity.ApprovalStatus; import com.example.rels.domain.lecture.entity.EnrollmentStatus; -import com.example.rels.domain.lecture.entity.LectureEnrollmentEntity; import com.example.rels.domain.lecture.entity.LectureEntity; import com.example.rels.domain.lecture.entity.LectureStatus; import com.example.rels.domain.lecture.repository.LectureEnrollmentCountProjection; import com.example.rels.domain.lecture.repository.LectureEnrollmentRepository; import com.example.rels.domain.lecture.repository.LectureRepository; -import com.example.rels.domain.lecture.service.LectureService; -import com.example.rels.domain.user.entity.Role; import com.example.rels.domain.user.entity.UserEntity; import com.example.rels.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LectureServiceTest { @@ -63,46 +56,60 @@ class LectureServiceTest { @Mock private UserRepository userRepository; + @Mock + private LectureTimeValidator timeValidator; + + @Mock + private LectureLifecycleHandler lifecycleHandler; + private LectureService lectureService; @BeforeEach void setUp() { - lectureService = new LectureService(lectureRepository, lectureEnrollmentRepository, userRepository); - - LectureEnrollmentEntity savedMock = org.mockito.Mockito.mock(LectureEnrollmentEntity.class); - org.mockito.Mockito.lenient().when(savedMock.getRequestedAt()).thenReturn(LocalDateTime.now()); - org.mockito.Mockito.lenient().when(lectureEnrollmentRepository.save(org.mockito.ArgumentMatchers.any(LectureEnrollmentEntity.class))) - .thenReturn(savedMock); + lectureService = new LectureService( + lectureRepository, + lectureEnrollmentRepository, + userRepository, + timeValidator, + lifecycleHandler + ); } @Test void getLecturesUsesBulkEnrollmentCounts() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - - LectureEntity firstLecture = new LectureEntity("title1", "description1", creator, "장소1", java.time.LocalDate.now(), java.time.LocalTime.NOON, LocalDateTime.now().plusDays(1), null); - LectureEntity secondLecture = new LectureEntity("title2", "description2", creator, "장소2", java.time.LocalDate.now(), java.time.LocalTime.NOON, LocalDateTime.now().plusDays(1), null); - setId(firstLecture, 11L); - setId(secondLecture, 12L); - setCreatedAt(firstLecture, LocalDateTime.now()); - setCreatedAt(secondLecture, LocalDateTime.now()); - setApprovalStatus(firstLecture, ApprovalStatus.APPROVED); - setApprovalStatus(secondLecture, ApprovalStatus.APPROVED); - - LectureEnrollmentCountProjection enrolledCount = org.mockito.Mockito.mock(LectureEnrollmentCountProjection.class); - when(enrolledCount.getLectureId()).thenReturn(11L); - when(enrolledCount.getStatus()).thenReturn(EnrollmentStatus.ENROLLED); - when(enrolledCount.getEnrollmentCount()).thenReturn(3L); - - LectureEnrollmentCountProjection waitingCount = org.mockito.Mockito.mock(LectureEnrollmentCountProjection.class); - when(waitingCount.getLectureId()).thenReturn(11L); - when(waitingCount.getStatus()).thenReturn(EnrollmentStatus.WAITING); - when(waitingCount.getEnrollmentCount()).thenReturn(1L); + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + + LectureEntity firstLecture = TestEntityFactory.createLecture("title1", "description1", creator, "장소1", LocalDate.now(), LocalTime.NOON, LocalDateTime.now().plusDays(1), null, 11L); + LectureEntity secondLecture = TestEntityFactory.createLecture("title2", "description2", creator, "장소2", LocalDate.now(), LocalTime.NOON, LocalDateTime.now().plusDays(1), null, 12L); Pageable pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); - when(lectureRepository.findAllByApprovalStatusOrCreatorIdOrderByCreatedAtDesc(eq(ApprovalStatus.APPROVED), eq(2L), any())) + when(lectureRepository.findVisibleToUser(eq(ApprovalStatus.APPROVED), eq(2L), any())) .thenReturn(new PageImpl<>(List.of(firstLecture, secondLecture), pageable, 2)); - when(lectureEnrollmentRepository.countEnrollmentsByLectureIds(List.of(11L, 12L))).thenReturn(List.of(enrolledCount, waitingCount)); + + LectureEnrollmentCountProjection projection1_enrolled = mock(LectureEnrollmentCountProjection.class); + when(projection1_enrolled.getLectureId()).thenReturn(11L); + when(projection1_enrolled.getStatus()).thenReturn(EnrollmentStatus.ENROLLED); + when(projection1_enrolled.getEnrollmentCount()).thenReturn(3L); + + LectureEnrollmentCountProjection projection1_waiting = mock(LectureEnrollmentCountProjection.class); + when(projection1_waiting.getLectureId()).thenReturn(11L); + when(projection1_waiting.getStatus()).thenReturn(EnrollmentStatus.WAITING); + when(projection1_waiting.getEnrollmentCount()).thenReturn(1L); + + LectureEnrollmentCountProjection projection2_enrolled = mock(LectureEnrollmentCountProjection.class); + when(projection2_enrolled.getLectureId()).thenReturn(12L); + when(projection2_enrolled.getStatus()).thenReturn(EnrollmentStatus.ENROLLED); + when(projection2_enrolled.getEnrollmentCount()).thenReturn(0L); + + LectureEnrollmentCountProjection projection2_waiting = mock(LectureEnrollmentCountProjection.class); + when(projection2_waiting.getLectureId()).thenReturn(12L); + when(projection2_waiting.getStatus()).thenReturn(EnrollmentStatus.WAITING); + when(projection2_waiting.getEnrollmentCount()).thenReturn(0L); + + when(lectureEnrollmentRepository.countEnrollmentsByLectureIds(List.of(11L, 12L))) + .thenReturn(List.of(projection1_enrolled, projection1_waiting, projection2_enrolled, projection2_waiting)); + + doNothing().when(lifecycleHandler).refreshLectureLifecycle(any(LectureEntity.class), any(LocalDateTime.class), anyLong()); Page lectures = lectureService.getLectures(pageable, 2L); @@ -113,483 +120,99 @@ void getLecturesUsesBulkEnrollmentCounts() { assertEquals(0L, lectures.getContent().get(1).enrolledCount()); assertEquals(0L, lectures.getContent().get(1).waitingCount()); - verify(lectureEnrollmentRepository).countEnrollmentsByLectureIds(List.of(11L, 12L)); verifyNoInteractions(userRepository); } @Test void getLecturesMarksEndedLectureAsClosed() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity endedLecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().minusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), null, 11L); - LectureEntity endedLecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().minusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), null); - setId(endedLecture, 11L); - setCreatedAt(endedLecture, LocalDateTime.now().minusDays(2)); - setApprovalStatus(endedLecture, ApprovalStatus.APPROVED); + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + when(lectureRepository.findVisibleToUser(eq(ApprovalStatus.APPROVED), eq(2L), any())) + .thenReturn(new PageImpl<>(List.of(endedLecture), pageable, 1)); - LectureEnrollmentCountProjection enrolledCount = org.mockito.Mockito.mock(LectureEnrollmentCountProjection.class); - when(enrolledCount.getLectureId()).thenReturn(11L); - when(enrolledCount.getStatus()).thenReturn(EnrollmentStatus.ENROLLED); - when(enrolledCount.getEnrollmentCount()).thenReturn(0L); + LectureEnrollmentCountProjection projection_enrolled = mock(LectureEnrollmentCountProjection.class); + when(projection_enrolled.getLectureId()).thenReturn(11L); + when(projection_enrolled.getStatus()).thenReturn(EnrollmentStatus.ENROLLED); + when(projection_enrolled.getEnrollmentCount()).thenReturn(0L); - LectureEnrollmentCountProjection waitingCount = org.mockito.Mockito.mock(LectureEnrollmentCountProjection.class); - when(waitingCount.getLectureId()).thenReturn(11L); - when(waitingCount.getStatus()).thenReturn(EnrollmentStatus.WAITING); - when(waitingCount.getEnrollmentCount()).thenReturn(0L); + LectureEnrollmentCountProjection projection_waiting = mock(LectureEnrollmentCountProjection.class); + when(projection_waiting.getLectureId()).thenReturn(11L); + when(projection_waiting.getStatus()).thenReturn(EnrollmentStatus.WAITING); + when(projection_waiting.getEnrollmentCount()).thenReturn(0L); - Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); - when(lectureRepository.findAllByApprovalStatusOrCreatorIdOrderByCreatedAtDesc(eq(ApprovalStatus.APPROVED), eq(2L), any())) - .thenReturn(new PageImpl<>(List.of(endedLecture), pageable, 1)); - when(lectureEnrollmentRepository.countEnrollmentsByLectureIds(List.of(11L))).thenReturn(List.of(enrolledCount, waitingCount)); + when(lectureEnrollmentRepository.countEnrollmentsByLectureIds(List.of(11L))) + .thenReturn(List.of(projection_enrolled, projection_waiting)); + + doNothing().when(lifecycleHandler).refreshLectureLifecycle(any(LectureEntity.class), any(LocalDateTime.class), anyLong()); Page lectures = lectureService.getLectures(pageable, 2L); - assertEquals(LectureStatus.CLOSE.name(), lectures.getContent().getFirst().lectureStatus()); + assertEquals(LectureStatus.CLOSE.name(), lectures.getContent().get(0).lectureStatus()); } @Test void getLectureDetailMarksEndedLectureAsClosed() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - - LectureEntity endedLecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().minusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), null); - setId(endedLecture, 11L); - setCreatedAt(endedLecture, LocalDateTime.now().minusDays(2)); - setApprovalStatus(endedLecture, ApprovalStatus.APPROVED); + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity endedLecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().minusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), null, 11L); when(lectureRepository.findById(11L)).thenReturn(Optional.of(endedLecture)); when(lectureEnrollmentRepository.countByLectureIdAndStatus(11L, EnrollmentStatus.ENROLLED)).thenReturn(0L); when(lectureEnrollmentRepository.countByLectureIdAndStatus(11L, EnrollmentStatus.WAITING)).thenReturn(0L); when(lectureEnrollmentRepository.findByLectureIdAndUserId(11L, 2L)).thenReturn(Optional.empty()); + doNothing().when(lifecycleHandler).refreshLectureLifecycle(any(LectureEntity.class), any(LocalDateTime.class)); + LectureDetailResponse response = lectureService.getLectureDetail(11L, 2L, Role.USER); assertEquals(LectureStatus.CLOSE.name(), response.lectureStatus()); assertEquals(LectureStatus.CLOSE, endedLecture.getStatus()); } - @Test - void enrollRejectsEndedLecture() { - LectureEntity lecture = new LectureEntity("title", "description", new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", LocalDate.now().minusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), null); - setId(lecture, 1L); - setCreatedAt(lecture, LocalDateTime.now().minusDays(2)); - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> lectureService.enroll(1L, 2L)); - - assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); - assertEquals(LectureStatus.CLOSE, lecture.getStatus()); - } - - @Test - @DisplayName("수강 신청 오픈 시간 전 신청 시 예외가 발생한다") - void enrollRejectsBeforeOpenTime() { - LectureEntity lecture = new LectureEntity("title", "description", new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", LocalDate.now().plusDays(2), LocalTime.NOON, LocalDateTime.now().plusDays(2), 30); - setId(lecture, 1L); - - // 생성 시각을 오늘 17:00로 설정하여 오픈 시각(내일 16:20) 이전 신청 상황을 연출 - setCreatedAt(lecture, LocalDateTime.now().toLocalDate().atTime(17, 0)); - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> lectureService.enroll(1L, 2L)); - - assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); - assertTrue(exception.getReason().contains("오후 4시 20분부터 가능합니다.")); - } - - @Test - void enrollConfirmsLectureAtThreshold() { - LectureEntity lecture = new LectureEntity("title", "description", new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", java.time.LocalDate.now().plusDays(1), java.time.LocalTime.NOON, LocalDateTime.now().plusDays(1), 30); - setCreatedAt(lecture, LocalDateTime.now().minusDays(2)); // 이미 신청 시간이 오픈된 강연 - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - UserEntity applicant = new UserEntity("user@test.com", "user", "1000000001", Role.USER); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(9L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); - - EnrollmentResponse response = lectureService.enroll(1L, 2L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(LectureEnrollmentEntity.class); - verify(lectureEnrollmentRepository).save(captor.capture()); - LectureEnrollmentEntity saved = captor.getValue(); - - assertSame(lecture, saved.getLecture()); - assertSame(applicant, saved.getUser()); - assertEquals(EnrollmentStatus.ENROLLED, saved.getStatus()); - assertEquals(LectureStatus.CONFIRMED, lecture.getStatus()); - assertEquals("ENROLLED", response.enrollmentStatus()); - assertEquals(10L, response.enrolledCount()); - assertEquals(0L, response.waitingCount()); - } - - @Test - void enrollConfirmsLectureAboveThreshold() { - LectureEntity lecture = new LectureEntity("title", "description", new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", java.time.LocalDate.now().plusDays(1), java.time.LocalTime.NOON, LocalDateTime.now().plusDays(1), 30); - setCreatedAt(lecture, LocalDateTime.now().minusDays(2)); // 이미 신청 시간이 오픈된 강연 - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - UserEntity applicant = new UserEntity("user@test.com", "user", "1000000001", Role.USER); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(10L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); - - EnrollmentResponse response = lectureService.enroll(1L, 2L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(LectureEnrollmentEntity.class); - verify(lectureEnrollmentRepository).save(captor.capture()); - - assertEquals(LectureStatus.CONFIRMED, lecture.getStatus()); - assertEquals("ENROLLED", response.enrollmentStatus()); - assertEquals(11L, response.enrolledCount()); - } - @Test void createLectureRejectsTotalAndGradeCapacityTogether() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - org.mockito.Mockito.lenient().when(userRepository.findById(1L)).thenReturn(Optional.of(creator)); - - var request = new LectureCreateRequest( - "title", - "description", - Map.of(1, 10), - 20, - "장소", - LocalDate.now().plusDays(1), - LocalTime.NOON, - LocalDateTime.now().plusDays(1)); - - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> lectureService.createLecture(1L, request)); + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); - assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); - } + LocalDateTime deadline = LocalDateTime.now().plusHours(12); + var request = new LectureCreateRequest("title", "description", Map.of(1, 10), 20, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, deadline, Set.of()); - @Test - void enrollMovesToWaitingAfterCapacity() { - LectureEntity lecture = new LectureEntity("title", "description", new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", java.time.LocalDate.now().plusDays(1), java.time.LocalTime.NOON, LocalDateTime.now().plusDays(1), 30); - setCreatedAt(lecture, LocalDateTime.now().minusDays(2)); // 이미 신청 시간이 오픈된 강연 - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - UserEntity applicant = new UserEntity("user@test.com", "user", "1000000001", Role.USER); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(30L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(4L); - - EnrollmentResponse response = lectureService.enroll(1L, 2L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(LectureEnrollmentEntity.class); - verify(lectureEnrollmentRepository).save(captor.capture()); - LectureEnrollmentEntity saved = captor.getValue(); - - assertEquals(EnrollmentStatus.WAITING, saved.getStatus()); - assertEquals(LectureStatus.OPEN, lecture.getStatus()); - assertEquals("WAITING", response.enrollmentStatus()); - assertEquals(30L, response.enrolledCount()); - assertEquals(5L, response.waitingCount()); - } - - @Test - void cancelPromotesFirstWaitingUser() { - LectureEntity lecture = new LectureEntity("title", "description", new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", java.time.LocalDate.now(), java.time.LocalTime.NOON, LocalDateTime.now().plusDays(1), null); - setId(lecture, 1L); - setCreatedAt(lecture, LocalDateTime.now().minusDays(1)); - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - UserEntity applicant = new UserEntity("user@test.com", "user", "1000000001", Role.USER); - UserEntity waitingUser = new UserEntity("wait@test.com", "wait", "1000000002", Role.USER); - - LectureEnrollmentEntity enrolled = new LectureEnrollmentEntity(lecture, applicant, EnrollmentStatus.ENROLLED); - LectureEnrollmentEntity waiting = new LectureEnrollmentEntity(lecture, waitingUser, EnrollmentStatus.WAITING); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(enrolled)); - when(lectureEnrollmentRepository.findAllByLectureId(1L)).thenReturn(List.of(waiting)); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(30L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(2L); - - EnrollmentResponse response = lectureService.cancelEnrollment(1L, 2L); - - verify(lectureEnrollmentRepository).delete(enrolled); - assertEquals(EnrollmentStatus.ENROLLED, waiting.getStatus()); - assertEquals("CANCELED", response.enrollmentStatus()); - assertEquals(30L, response.enrolledCount()); - assertEquals(2L, response.waitingCount()); - assertNotNull(response.lectureId()); - } - - @Test - void enrollReadsGradeFromFirstDigitOfStudentNumber() { - // 학번 "3204"는 3학년 2반이다. 두 번째 자리를 읽으면 2학년으로 잘못 판정된다. - LectureEntity lecture = gradeCapacityLecture(Map.of(1, 1, 2, 1, 3, 1), LocalDateTime.now().plusDays(1)); - UserEntity secondGrade = new UserEntity("second@test.com", "second", "2204", Role.USER); - UserEntity applicant = new UserEntity("third@test.com", "third", "3204", Role.USER); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(1L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); - when(lectureEnrollmentRepository.findAllByLectureId(1L)) - .thenReturn(List.of(enrollment(lecture, secondGrade, EnrollmentStatus.ENROLLED, 1L))); - - EnrollmentResponse response = lectureService.enroll(1L, 2L); - - assertEquals("ENROLLED", response.enrollmentStatus()); - } - - @Test - void enrollMovesToWaitingWhenGradeHasNoSeat() { - LectureEntity lecture = gradeCapacityLecture(Map.of(1, 5, 2, 5), LocalDateTime.now().plusDays(1)); - UserEntity applicant = new UserEntity("third@test.com", "third", "3204", Role.USER); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(0L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); - - EnrollmentResponse response = lectureService.enroll(1L, 2L); - - assertEquals("WAITING", response.enrollmentStatus()); - } - - @Test - void cancelPromotesWaitingUserOfTheGradeThatFreedUpSeat() { - LectureEntity lecture = gradeCapacityLecture(Map.of(1, 1, 2, 1), LocalDateTime.now().plusDays(1)); - UserEntity firstGrade = new UserEntity("first@test.com", "first", "1101", Role.USER); - UserEntity secondGrade = new UserEntity("second@test.com", "second", "2101", Role.USER); - - LectureEnrollmentEntity canceled = enrollment(lecture, firstGrade, EnrollmentStatus.ENROLLED, 1L); - LectureEnrollmentEntity stillEnrolled = enrollment(lecture, secondGrade, EnrollmentStatus.ENROLLED, 2L); - // 2학년이 먼저 대기를 걸었지만 2학년 자리는 그대로 차 있다. - LectureEnrollmentEntity waitingSecondGrade = enrollment(lecture, - new UserEntity("second2@test.com", "second2", "2102", Role.USER), EnrollmentStatus.WAITING, 3L); - LectureEnrollmentEntity waitingFirstGrade = enrollment(lecture, - new UserEntity("first2@test.com", "first2", "1102", Role.USER), EnrollmentStatus.WAITING, 4L); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.of(canceled)); - when(lectureEnrollmentRepository.findAllByLectureId(1L)) - .thenReturn(List.of(stillEnrolled, waitingSecondGrade, waitingFirstGrade)); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(2L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(1L); - - lectureService.cancelEnrollment(1L, 2L); - - assertEquals(EnrollmentStatus.WAITING, waitingSecondGrade.getStatus()); - assertEquals(EnrollmentStatus.ENROLLED, waitingFirstGrade.getStatus()); - } - - @Test - void syncPromotesWaitingUsersUpToTotalCapacityAfterDeadline() { - // 학년 정원은 신청받는 동안만 적용한다. 마감 뒤에는 남은 자리를 순번대로 채운다. - LectureEntity lecture = gradeCapacityLecture(Map.of(1, 1, 2, 1, 3, 1), LocalDateTime.now().minusHours(1)); - UserEntity firstGrade = new UserEntity("first@test.com", "first", "1101", Role.USER); - - LectureEnrollmentEntity enrolled = enrollment(lecture, firstGrade, EnrollmentStatus.ENROLLED, 1L); - LectureEnrollmentEntity firstWaiting = enrollment(lecture, - new UserEntity("second@test.com", "second", "2101", Role.USER), EnrollmentStatus.WAITING, 2L); - LectureEnrollmentEntity secondWaiting = enrollment(lecture, - new UserEntity("second2@test.com", "second2", "2102", Role.USER), EnrollmentStatus.WAITING, 3L); - LectureEnrollmentEntity thirdWaiting = enrollment(lecture, - new UserEntity("second3@test.com", "second3", "2103", Role.USER), EnrollmentStatus.WAITING, 4L); - - when(lectureRepository.findAll()).thenReturn(List.of(lecture)); - when(lectureEnrollmentRepository.findAllByLectureId(1L)) - .thenReturn(List.of(enrolled, firstWaiting, secondWaiting, thirdWaiting)); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(3L); - - lectureService.syncLectureStatuses(); - - assertEquals(EnrollmentStatus.ENROLLED, firstWaiting.getStatus()); - assertEquals(EnrollmentStatus.ENROLLED, secondWaiting.getStatus()); - // 정원 3명을 채웠으므로 네 번째는 그대로 대기다. - assertEquals(EnrollmentStatus.WAITING, thirdWaiting.getStatus()); + var exception = assertThrows(ResponseStatusException.class, () -> lectureService.createLecture(1L, request)); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); } @Test - void enrollOpensAtSchoolTimeEvenThoughServerRunsInUtc() { - // 서버는 UTC로 돌지만 16:20은 한국 시간 기준이다. UTC now로 비교하면 - // 한국 시간 오후 내내 "아직 신청 전"이 되어 403이 난다. - LocalDateTime nowInSchoolTime = LocalDateTime.of(2026, 8, 27, 20, 34); - LectureService service = new LectureService(lectureRepository, lectureEnrollmentRepository, userRepository) { - @Override - protected LocalDateTime schoolTimeNow() { - return nowInSchoolTime; - } - }; - - LectureEntity lecture = new LectureEntity("title", "description", - new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", - LocalDate.of(2026, 8, 29), LocalTime.NOON, LocalDateTime.of(2026, 8, 28, 23, 0), 30); - setId(lecture, 1L); - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - // 한국 시간 2026-08-27 10:00에 개설한 강연은 서버에 UTC 01:00으로 찍힌다. - setCreatedAt(lecture, LocalDateTime.of(2026, 8, 27, 1, 0)); - - UserEntity applicant = new UserEntity("user@test.com", "user", "2204", Role.USER); - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - when(userRepository.findById(2L)).thenReturn(Optional.of(applicant)); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); + void createLectureAllowsCapacityAboveThirty() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + + LocalDateTime deadline = LocalDateTime.now().plusHours(12); + var request = new LectureCreateRequest("title", "description", null, 31, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, deadline, Set.of()); + + when(userRepository.findById(1L)).thenReturn(Optional.of(creator)); + doNothing().when(timeValidator).validateApplicationDeadline(any(), any(), any()); + when(lectureRepository.save(any(LectureEntity.class))).thenAnswer(invocation -> { + LectureEntity lecture = invocation.getArgument(0); + TestEntityFactory.setId(lecture, 1L); + return lecture; + }); when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(0L); when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); + when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 1L)).thenReturn(Optional.empty()); - EnrollmentResponse response = service.enroll(1L, 2L); - - assertEquals("ENROLLED", response.enrollmentStatus()); - } - - @Test - void enrollRejectsBeforeSchoolTimeOpens() { - LocalDateTime nowInSchoolTime = LocalDateTime.of(2026, 8, 27, 15, 0); - LectureService service = new LectureService(lectureRepository, lectureEnrollmentRepository, userRepository) { - @Override - protected LocalDateTime schoolTimeNow() { - return nowInSchoolTime; - } - }; - - LectureEntity lecture = new LectureEntity("title", "description", - new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", - LocalDate.of(2026, 8, 29), LocalTime.NOON, LocalDateTime.of(2026, 8, 28, 23, 0), 30); - setId(lecture, 1L); - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - setCreatedAt(lecture, LocalDateTime.of(2026, 8, 27, 1, 0)); - - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> service.enroll(1L, 2L)); - - assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); - } - - @Test - void cancelRejectsAfterApplicationDeadline() { - LectureEntity lecture = gradeCapacityLecture(Map.of(1, 5, 2, 5), LocalDateTime.now().minusHours(1)); - when(lectureRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(lecture)); - - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> lectureService.cancelEnrollment(1L, 2L)); - - assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); - } - - /** 학년별 정원으로 만든 강연. 강연 자체는 아직 끝나지 않은 시각으로 둔다. */ - private LectureEntity gradeCapacityLecture(Map capacityByGrade, LocalDateTime applicationDeadline) { - LectureEntity lecture = new LectureEntity("title", "description", - new UserEntity("creator@test.com", "creator", "1000000000", Role.USER), "장소", - LocalDate.now().plusDays(7), LocalTime.NOON, applicationDeadline, null); - setId(lecture, 1L); - setApprovalStatus(lecture, ApprovalStatus.APPROVED); - // 신청은 개설 당일 16:20부터 열린다. 이미 열린 강연으로 둔다. - setCreatedAt(lecture, LocalDateTime.now().minusDays(2).toLocalDate().atTime(9, 0)); - lecture.setCapacityByGrade(capacityByGrade); - return lecture; - } - - private LectureEnrollmentEntity enrollment(LectureEntity lecture, UserEntity user, EnrollmentStatus status, Long id) { - LectureEnrollmentEntity enrollment = new LectureEnrollmentEntity(lecture, user, status); - try { - Field idField = LectureEnrollmentEntity.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(enrollment, id); - Field requestedAtField = LectureEnrollmentEntity.class.getDeclaredField("requestedAt"); - requestedAtField.setAccessible(true); - requestedAtField.set(enrollment, LocalDateTime.now().minusDays(1).plusMinutes(id)); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException(e); - } - return enrollment; - } - - private void setId(LectureEntity lecture, Long id) { - try { - Field field = LectureEntity.class.getDeclaredField("id"); - field.setAccessible(true); - field.set(lecture, id); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("id 설정 실패", e); - } - } - - private void setCreatedAt(LectureEntity lecture, LocalDateTime createdAt) { - try { - Field field = LectureEntity.class.getSuperclass().getDeclaredField("createdAt"); - field.setAccessible(true); - field.set(lecture, createdAt); - } catch (NoSuchFieldException e) { - try { - Field field = LectureEntity.class.getDeclaredField("createdAt"); - field.setAccessible(true); - field.set(lecture, createdAt); - } catch (ReflectiveOperationException ex) { - throw new IllegalStateException("createdAt 설정 실패", ex); - } - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("createdAt 설정 실패", e); - } - } - - private void setApprovalStatus(LectureEntity lecture, ApprovalStatus status) { - try { - Field field = LectureEntity.class.getDeclaredField("approvalStatus"); - field.setAccessible(true); - field.set(lecture, status); - } catch (ReflectiveOperationException e) { - try { - lecture.updateApprovalStatus(status, null); - } catch (Exception ex) { - throw new IllegalStateException("approvalStatus 설정 실패", ex); - } - } - } - - private void setId(UserEntity user) { - setId(user, 1L); - } + LectureDetailResponse response = lectureService.createLecture(1L, request); - private void setId(UserEntity user, Long id) { - try { - Field field = UserEntity.class.getDeclaredField("id"); - field.setAccessible(true); - field.set(user, id); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("id 설정 실패", e); - } + assertEquals(31, response.totalCapacity()); } @Test void updateLectureAllowsAdminToModifyOtherUserLecture() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - UserEntity admin = new UserEntity("admin@test.com", "admin", "2000000000", Role.ADMIN); - setId(admin, 2L); - - LectureEntity lecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20); - setId(lecture, 1L); - - LectureUpdateRequest request = new LectureUpdateRequest( - "updated title", - "updated description", - null, - 20, - "updated 장소", - LocalDate.now().plusDays(2), - LocalTime.NOON, - LocalDateTime.now().plusDays(2) - ); + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20, 1L); + + LocalDateTime deadline = LocalDateTime.now().plusHours(12); + LectureUpdateRequest request = new LectureUpdateRequest("updated title", "updated description", null, 20, "updated 장소", LocalDate.now().plusDays(2), LocalTime.NOON, deadline, Set.of()); + doNothing().when(timeValidator).validateApplicationDeadline(any(), any(), any()); when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(0L); when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); @@ -601,114 +224,42 @@ void updateLectureAllowsAdminToModifyOtherUserLecture() { assertEquals("updated description", response.description()); } - @Test - void deleteLectureAllowsAdminToDeleteOtherUserLecture() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - UserEntity admin = new UserEntity("admin@test.com", "admin", "2000000000", Role.ADMIN); - setId(admin, 2L); - - LectureEntity lecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20); - setId(lecture, 1L); - - when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); - - lectureService.deleteLecture(1L, 2L, Role.ADMIN); - - verify(lectureEnrollmentRepository).deleteByLectureId(1L); - verify(lectureRepository).delete(lecture); - } - @Test void updateLectureRejectsUserFromModifyingOtherUserLecture() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - UserEntity otherUser = new UserEntity("other@test.com", "other", "2000000000", Role.USER); - setId(otherUser, 2L); - - LectureEntity lecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20); - setId(lecture, 1L); - - LectureUpdateRequest request = new LectureUpdateRequest( - "updated title", - "updated description", - null, - 20, - "updated 장소", - LocalDate.now().plusDays(2), - LocalTime.NOON, - LocalDateTime.now().plusDays(2) - ); - - when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); - - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> lectureService.updateLecture(1L, 2L, Role.USER, request)); + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20, 1L); - assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); - } - - @Test - void deleteLectureRejectsUserFromDeletingOtherUserLecture() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - UserEntity otherUser = new UserEntity("other@test.com", "other", "2000000000", Role.USER); - setId(otherUser, 2L); - - LectureEntity lecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20); - setId(lecture, 1L); + LocalDateTime deadline = LocalDateTime.now().plusHours(12); + LectureUpdateRequest request = new LectureUpdateRequest("updated title", "updated description", null, 20, "updated 장소", LocalDate.now().plusDays(2), LocalTime.NOON, deadline, Set.of()); + doNothing().when(timeValidator).validateApplicationDeadline(any(), any(), any()); when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); - var exception = assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> lectureService.deleteLecture(1L, 2L, Role.USER)); - + var exception = assertThrows(ResponseStatusException.class, () -> lectureService.updateLecture(1L, 2L, Role.USER, request)); assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); } @Test - void updateLectureAllowsCreatorToModifyOwnLecture() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - - LectureEntity lecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20); - setId(lecture, 1L); + void deleteLectureAllowsAdminToDeleteOtherUserLecture() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20, 1L); when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.ENROLLED)).thenReturn(0L); - when(lectureEnrollmentRepository.countByLectureIdAndStatus(1L, EnrollmentStatus.WAITING)).thenReturn(0L); - when(lectureEnrollmentRepository.findByLectureIdAndUserId(1L, 1L)).thenReturn(Optional.empty()); - LectureUpdateRequest request = new LectureUpdateRequest( - "updated title", - "updated description", - null, - 20, - "updated 장소", - LocalDate.now().plusDays(2), - LocalTime.NOON, - LocalDateTime.now().plusDays(2) - ); - - LectureDetailResponse response = lectureService.updateLecture(1L, 1L, Role.USER, request); + lectureService.deleteLecture(1L, 2L, Role.ADMIN); - assertEquals("updated title", response.title()); - assertEquals("updated description", response.description()); + verify(lectureEnrollmentRepository).deleteByLectureId(1L); + verify(lectureRepository).delete(lecture); } @Test - void deleteLectureAllowsCreatorToDeleteOwnLecture() { - UserEntity creator = new UserEntity("creator@test.com", "creator", "1000000000", Role.USER); - setId(creator); - - LectureEntity lecture = new LectureEntity("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20); - setId(lecture, 1L); + void deleteLectureRejectsUserFromDeletingOtherUserLecture() { + UserEntity creator = TestEntityFactory.createUser("creator@test.com", "creator", "1000000000", Role.USER, 1L); + LectureEntity lecture = TestEntityFactory.createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(1), LocalTime.NOON, LocalDateTime.now().plusDays(1), 20, 1L); when(lectureRepository.findById(1L)).thenReturn(Optional.of(lecture)); - lectureService.deleteLecture(1L, 1L, Role.USER); - - verify(lectureEnrollmentRepository).deleteByLectureId(1L); - verify(lectureRepository).delete(lecture); + var exception = assertThrows(ResponseStatusException.class, () -> lectureService.deleteLecture(1L, 2L, Role.USER)); + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); } } \ No newline at end of file diff --git a/src/test/java/com/example/rels/lecture/service/LectureTimeValidatorTest.java b/src/test/java/com/example/rels/lecture/service/LectureTimeValidatorTest.java new file mode 100644 index 0000000..1f8a631 --- /dev/null +++ b/src/test/java/com/example/rels/lecture/service/LectureTimeValidatorTest.java @@ -0,0 +1,39 @@ +package com.example.rels.lecture.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import com.example.rels.domain.lecture.service.LectureTimeValidator; + +class LectureTimeValidatorTest { + + private final LectureTimeValidator validator = new LectureTimeValidator(); + + @Test + void approvalBeforeFourTwentyOpensSameDayAtFourTwenty() { + LocalDateTime approval = LocalDateTime.of(2026, 9, 2, 15, 0); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> validator.validateApplicationTime(approval, null, LocalDateTime.of(2026, 9, 2, 16, 19))); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals("수강 신청은 2026-09-02 오후 4시 20분부터 가능합니다.", exception.getReason()); + } + + @Test + void approvalAfterFourTwentyOpensNextDayAtFourTwenty() { + LocalDateTime approval = LocalDateTime.of(2026, 9, 2, 16, 21); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> validator.validateApplicationTime(approval, null, LocalDateTime.of(2026, 9, 3, 16, 19))); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals("수강 신청은 2026-09-03 오후 4시 20분부터 가능합니다.", exception.getReason()); + } +} diff --git a/src/test/java/com/example/rels/lecture/service/TestEntityFactory.java b/src/test/java/com/example/rels/lecture/service/TestEntityFactory.java new file mode 100644 index 0000000..8b9330d --- /dev/null +++ b/src/test/java/com/example/rels/lecture/service/TestEntityFactory.java @@ -0,0 +1,77 @@ +package com.example.rels.lecture.service; + +import com.example.rels.domain.user.entity.Role; // 올바른 Role 패키지로 수정 +import com.example.rels.domain.lecture.entity.ApprovalStatus; +import com.example.rels.domain.lecture.entity.AttendanceStatus; +import com.example.rels.domain.lecture.entity.EnrollmentStatus; +import com.example.rels.domain.lecture.entity.LectureEnrollmentEntity; +import com.example.rels.domain.lecture.entity.LectureEntity; +import com.example.rels.domain.lecture.entity.LectureStatus; +import com.example.rels.domain.user.entity.UserEntity; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Map; + +public class TestEntityFactory { + + public static UserEntity createUser(String email, String name, String studentNumber, Role role, Long id) { + UserEntity user = new UserEntity(email, name, studentNumber, role); + setField(user, "id", id); + return user; + } + + public static LectureEntity createLecture(String title, String description, UserEntity creator, String location, + LocalDate lectureDate, LocalTime lectureTime, LocalDateTime deadline, + Integer totalCapacity, Long id) { + LectureEntity lecture = new LectureEntity(title, description, creator, location, lectureDate, lectureTime, deadline, totalCapacity); + setField(lecture, "id", id); + setField(lecture, "createdAt", LocalDateTime.now().minusDays(2)); + setField(lecture, "approvalStatus", ApprovalStatus.APPROVED); + + // 강의 날짜가 과거이면 상태를 CLOSE로 설정 + if (lectureDate != null && lectureDate.isBefore(LocalDate.now())) { + lecture.close(); + } else { + setField(lecture, "status", LectureStatus.OPEN); + } + return lecture; + } + + public static LectureEntity createGradeCapacityLecture(Map capacityByGrade, LocalDateTime applicationDeadline, Integer totalCapacity) { + UserEntity creator = createUser("creator@test.com", "creator", "1000000000", Role.USER, 100L); + LectureEntity lecture = createLecture("title", "description", creator, "장소", LocalDate.now().plusDays(7), LocalTime.NOON, applicationDeadline, totalCapacity, 1L); + lecture.setCapacityByGrade(capacityByGrade); + return lecture; + } + + public static LectureEnrollmentEntity createEnrollment(LectureEntity lecture, UserEntity user, EnrollmentStatus status, Long id) { + LectureEnrollmentEntity enrollment = new LectureEnrollmentEntity(lecture, user, status); + setField(enrollment, "id", id); + setField(enrollment, "requestedAt", LocalDateTime.now().minusDays(1).plusMinutes(id != null ? id : 0)); + setField(enrollment, "attendanceStatus", AttendanceStatus.NONE); + return enrollment; + } + + private static void setField(Object target, String fieldName, Object value) { + Class clazz = target.getClass(); + while (clazz != null) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + return; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } catch (IllegalAccessException e) { + throw new IllegalStateException(fieldName + " 필드 설정 오류", e); + } + } + } + + public static void setId(Object target, Long id) { + setField(target, "id", id); + } +}