diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java index 26928fcf..ed67e22d 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,14 +26,13 @@ import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; import com.threestar.trainus.global.annotation.LoginUser; +import com.threestar.trainus.global.dto.PageRequestDto; import com.threestar.trainus.global.unit.BaseResponse; import com.threestar.trainus.global.unit.PagedResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @Tag(name = "관리자 쿠폰 API", description = "관리자 쿠폰 생성,수정 및 삭제 관련 API") @@ -56,15 +56,18 @@ public ResponseEntity> createCoupon( @GetMapping @Operation(summary = "쿠폰 목록 조회", description = "관리자가 쿠폰 목록을 조회") public ResponseEntity> getCoupons( - @RequestParam(defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") - @Max(value = 1000, message = "페이지는 1000 이하여야 합니다.") int page, - @RequestParam(defaultValue = "5") @Min(value = 1, message = "limit는 1 이상이어야 합니다.") - @Max(value = 100, message = "limit는 100 이하여야 합니다.") int limit, + @Valid @ModelAttribute PageRequestDto pageRequestDto, @RequestParam(required = false) CouponStatus status, @RequestParam(required = false) CouponCategory category, @LoginUser Long loginUserId ) { - CouponListResponseDto couponsInfo = adminCouponService.getCoupons(page, limit, status, category, loginUserId); + CouponListResponseDto couponsInfo = adminCouponService.getCoupons( + pageRequestDto.getPage(), + pageRequestDto.getLimit(), + status, + category, + loginUserId + ); CouponListWrapperDto coupons = AdminCouponMapper.toCouponListWrapperDto(couponsInfo); diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusScheduler.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusScheduler.java new file mode 100644 index 00000000..5558f061 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusScheduler.java @@ -0,0 +1,110 @@ +package com.threestar.trainus.domain.coupon.admin.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponStatusScheduler { + + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + + /** + * 1분마다 쿠폰 상태를 확인하여 업데이트를 진행 + * - INACTIVE → ACTIVE (오픈 시간 도달) + * - ACTIVE → INACTIVE (마감 시간 도달) + */ + @Scheduled(cron = "0 * * * * *") // 1분마다 실행 + @Transactional + public void updateCouponStatus() { + LocalDateTime now = LocalDateTime.now(); + + try { + // 비활성화 → 활성화(오픈시간에 도달한 쿠폰들) + activateExpiredCoupons(now); + + // 활성화 → 비활성화(마감시간에 도달한 쿠폰들) + deactivateExpiredCoupons(now); + + } catch (Exception e) { + log.error("쿠폰 상태 업데이트 중 오류 발생", e); + } + } + + /** + * 유저쿠폰의 만료 상태를 확인하여 업데이트를 진행 + * - ACTIVE → INACTIVE (유효기간 종료) + */ + @Scheduled(cron = "0 * * * * *") // 1분 마다 실행 + @Transactional + public void updateUserCouponStatus() { + LocalDateTime now = LocalDateTime.now(); + + try { + expireUserCoupons(now); + } catch (Exception e) { + log.error("유저쿠폰 상태 업데이트 중 오류 발생", e); + } + } + + private void activateExpiredCoupons(LocalDateTime now) { + // 오픈 시간지났지만 아직 비활성화 상태인 쿠폰들 조회 + List couponsToActivate = couponRepository.findInactiveCouponsToActivate(now); + + if (!couponsToActivate.isEmpty()) { + for (Coupon coupon : couponsToActivate) { + coupon.updateStatus(CouponStatus.ACTIVE); + log.info("쿠폰 활성화: ID={}, 이름={}", coupon.getId(), coupon.getName()); + } + + couponRepository.saveAll(couponsToActivate); + log.info("총 {}개의 쿠폰이 활성화되었습니다.", couponsToActivate.size()); + } + } + + private void deactivateExpiredCoupons(LocalDateTime now) { + // 마감 시간이 지났지만 아직 활성화 상태인 쿠폰들 조회 + List couponsToDeactivate = couponRepository.findActiveCouponsToDeactivate(now); + + if (!couponsToDeactivate.isEmpty()) { + for (Coupon coupon : couponsToDeactivate) { + coupon.updateStatus(CouponStatus.INACTIVE); + log.info("쿠폰 비활성화: ID={}, 이름={}", coupon.getId(), coupon.getName()); + } + + couponRepository.saveAll(couponsToDeactivate); + log.info("총 {}개의 쿠폰이 비활성화되었습니다.", couponsToDeactivate.size()); + } + } + + private void expireUserCoupons(LocalDateTime now) { + // 유효기간이 지났지만 아직 활성화 상태인 유저쿠폰들 조회 + List userCouponsToExpire = userCouponRepository.findActiveUserCouponsToExpire(now); + + if (!userCouponsToExpire.isEmpty()) { + for (UserCoupon userCoupon : userCouponsToExpire) { + userCoupon.use(); // 만료 처리 + log.info("유저쿠폰 만료: UserID={}, CouponID={}", + userCoupon.getUser().getId(), userCoupon.getCoupon().getId()); + } + + userCouponRepository.saveAll(userCouponsToExpire); + log.info("총 {}개의 유저쿠폰이 만료되었습니다.", userCouponsToExpire.size()); + } + } +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java index ed5d8d6f..017052c8 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java @@ -123,13 +123,13 @@ public CouponDeleteResponseDto deleteCoupon(Long couponId, Long userId) { throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); } - // todo : 발급된 쿠폰 수 조회 + // 발급된 쿠폰 수 조회 Long issuedCount = userCouponRepository.countByCouponId(couponId); - // todo : 삭제 가능 여부 검증 -> 지금은 필요없는데 일단은 만들어둠! + // 삭제 가능 여부 검증 (발급된 쿠폰이 있으면 예외 발생) validateCouponDeletion(coupon, issuedCount); - // 쿠폰 삭제 처리 + // 쿠폰 삭제 처리(아무도 발급받지 않은 쿠폰만) coupon.markAsDeleted(); Coupon deletedCoupon = couponRepository.save(coupon); @@ -338,7 +338,10 @@ private void updateCouponFields(Coupon coupon, CouponUpdateRequestDto request) { } private void validateCouponDeletion(Coupon coupon, Long issuedCount) { - //todo: 일단은 다 삭제가능하게 설정 해놨는데, 나중에 결제 붙으면여기에 여러 검증들을 추가할 예정 + // 발급된 쿠폰이 하나라도 있으면 삭제 불가 + if (issuedCount > 0) { + throw new BusinessException(ErrorCode.COUPON_CANNOT_DELETE_ISSUED); + } } //쿠폰id로 쿠폰을 조회하는 공통 메서드 diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java index 5d819baa..87d39f44 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java @@ -53,4 +53,25 @@ Page findCouponsWithFilters( @Param("category") CouponCategory category, Pageable pageable ); + + //활성화할 쿠폰을 찾는 메서드 + // 현재는 비활성화 상태이지만, 오픈시간에 도달했으면서 마감기한이 안지난 쿠폰들 + @Query(""" + SELECT c FROM Coupon c + WHERE c.status = com.threestar.trainus.domain.coupon.user.entity.CouponStatus.INACTIVE + AND c.openAt <= :now + AND c.closeAt > :now + AND c.deletedAt IS NULL + """) + List findInactiveCouponsToActivate(@Param("now") LocalDateTime now); + + //비활성화 할 쿠폰을 찾는 메서드 + //현재 활성화 상태이지만, 마감시간이 지난 쿠폰들을 검사 + @Query(""" + SELECT c FROM Coupon c + WHERE c.status = com.threestar.trainus.domain.coupon.user.entity.CouponStatus.ACTIVE + AND c.closeAt <= :now + AND c.deletedAt IS NULL + """) + List findActiveCouponsToDeactivate(@Param("now") LocalDateTime now); } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java index 6e50f082..fe7ce3b6 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java @@ -1,5 +1,6 @@ package com.threestar.trainus.domain.coupon.user.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -27,4 +28,14 @@ List findAllByUserIdAndStatusWithCoupon(@Param("userId") Long userId // 특정 사용자가 특정 쿠폰을 발급받은 수 조회 Long countByUserIdAndCouponId(Long userId, Long couponId); + + //만료시킬 유저쿠폰을 찾는 메서드 + //현재 활성화 상태이지만, 사용 유효기간이 지난 유저쿠폰들임! + @Query(""" + SELECT uc FROM UserCoupon uc + WHERE uc.status = com.threestar.trainus.domain.coupon.user.entity.CouponStatus.ACTIVE + AND uc.expirationDate <= :now + """) + List findActiveUserCouponsToExpire(@Param("now") LocalDateTime now); + } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/constants/LessonConstants.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/constants/LessonConstants.java new file mode 100644 index 00000000..bae8ccce --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/constants/LessonConstants.java @@ -0,0 +1,24 @@ +package com.threestar.trainus.domain.lesson.teacher.constants; + +//레슨 관련 상수 정의 +public final class LessonConstants { + + private LessonConstants() { + // Utility class - 인스턴스 생성 방지 + } + + //시간 관련 상수 + public static final class Time { + //레슨 수정/삭제 제한 시간 (시작 전 12시간) + public static final int EDIT_DELETE_LIMIT_HOURS = 12; + } + + //참가자 수 관련 상수 + public static final class Participants { + //일반 레슨 최대 참가자 수 + public static final int MAX_NORMAL_PARTICIPANTS = 100; + + //선착순 레슨 최대 참가자 수 + public static final int MAX_OPEN_RUN_PARTICIPANTS = 10000; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java index cde2226a..dea3e1ad 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/entity/Lesson.java @@ -221,4 +221,10 @@ public void updateAddressDetail(String addressDetail) { this.addressDetail = addressDetail; } } + + //레슨 상태 수정->스케쥴러꺼 + public void updateStatus(LessonStatus status) { + this.status = status; + } + } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java index ac398c67..2fe606bb 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonApplicationRepository.java @@ -5,19 +5,14 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.threestar.trainus.domain.lesson.teacher.entity.ApplicationStatus; import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; import com.threestar.trainus.domain.lesson.teacher.entity.LessonApplication; public interface LessonApplicationRepository extends JpaRepository { - - // 특정 레슨의 신청자 목록 조회(페이징) - Page findByLesson(Lesson lesson, Pageable pageable); - - //특정 레슨의 특정 상태 신청자 목록 조회(페이징) - Page findByLessonAndStatus(Lesson lesson, ApplicationStatus status, Pageable pageable); - // 신청 이력 확인 boolean existsByLessonIdAndUserId(Long lessonId, Long userId); @@ -27,7 +22,46 @@ public interface LessonApplicationRepository extends JpaRepository findByUserId(Long userId, Pageable pageable); Page findByUserIdAndStatus(Long userId, ApplicationStatus status, Pageable pageable); -} + int countByLessonAndStatus(Lesson lesson, ApplicationStatus status); + + // 모든 상태 신청자 조회 + @Query(""" + SELECT la FROM LessonApplication la + JOIN FETCH la.user u + JOIN FETCH u.profile + WHERE la.lesson = :lesson + ORDER BY la.createdAt DESC + """) + Page findByLessonWithUserAndProfile( + @Param("lesson") Lesson lesson, + Pageable pageable + ); + // 특정 상태 신청자 조회 + @Query(""" + SELECT la FROM LessonApplication la + JOIN FETCH la.user u + JOIN FETCH u.profile + WHERE la.lesson = :lesson AND la.status = :status + ORDER BY la.createdAt DESC + """) + Page findByLessonAndStatusWithUserAndProfile( + @Param("lesson") Lesson lesson, + @Param("status") ApplicationStatus status, + Pageable pageable + ); + // 승인된 참가자 조회 - 참가자 목록용 + @Query(""" + SELECT la FROM LessonApplication la + JOIN FETCH la.user u + JOIN FETCH u.profile + WHERE la.lesson = :lesson AND la.status = 'APPROVED' + ORDER BY la.createdAt ASC + """) + Page findApprovedParticipantsWithUserAndProfile( + @Param("lesson") Lesson lesson, + Pageable pageable + ); +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java index 19397f8c..a627ca33 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/repository/LessonRepository.java @@ -1,6 +1,7 @@ package com.threestar.trainus.domain.lesson.teacher.repository; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -58,10 +59,10 @@ boolean hasTimeConflictForUpdate( @Param("excludeLessonId") Long excludeLessonId ); - // 강사가 개설한 레슨 목록 조회 (페이징) + // 강사가 개설한 레슨 전체 목록 상태에 따라 필터링해서 조회(모집중인것만...이런식으로) Page findByLessonLeaderAndDeletedAtIsNull(Long lessonLeader, Pageable pageable); - // 강사가 개설한 레슨 목록 조회 (페이징+필터링) + // 강사가 개설한 레슨 전체 목록 조회 Page findByLessonLeaderAndStatusAndDeletedAtIsNull(Long lessonLeader, LessonStatus status, Pageable pageable); @@ -87,6 +88,29 @@ Page findBySearchConditions( Pageable pageable ); + // 시작할 레슨을 찾는 메서드 + // 모집중이거나 모집완료 상태일 때, 시작 시간이 도달한 레슨 + @Query(""" + SELECT l FROM Lesson l + WHERE l.status IN ( + com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.RECRUITING, + com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.RECRUITMENT_COMPLETED + ) + AND l.startAt <= :now + AND l.deletedAt IS NULL + """) + List findLessonsToStart(@Param("now") LocalDateTime now); + + //완료할 레슨을 찾는 메서드 + //현재는 진행중 -> 종료시간이 지나면 종료중으로 바뀔 레슨 + @Query(""" + SELECT l FROM Lesson l + WHERE l.status = com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus.IN_PROGRESS + AND l.endAt <= :now + AND l.deletedAt IS NULL + """) + List findLessonsToComplete(@Param("now") LocalDateTime now); + @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 적용 @Query("SELECT l FROM Lesson l WHERE l.id = :lessonId") Optional findByIdWithLock(@Param("lessonId") Long lessonId); diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusScheduler.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusScheduler.java new file mode 100644 index 00000000..550dc68f --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusScheduler.java @@ -0,0 +1,77 @@ +package com.threestar.trainus.domain.lesson.teacher.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonStatus; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LessonStatusScheduler { + + private final LessonRepository lessonRepository; + + /** + * 1분마다 레슨 상태를 확인하여 업데이트를 진행 + * - 모집중/모집완료 → 진행중 (시작 시간 도달) + * - 진행중 → 완료 (종료 시간 도달) + */ + @Scheduled(cron = "0 * * * * *") //1분마다 실행 + @Transactional + public void updateLessonStatus() { + LocalDateTime now = LocalDateTime.now(); + + try { + // 모집중/모집완료 → 진행중(시작시간 도달) + startLessons(now); + + // 진행중 → 완료(종료시간 도달) + completeLessons(now); + + } catch (Exception e) { + log.error("레슨 상태 업데이트 중 오류 발생", e); + } + } + + private void startLessons(LocalDateTime now) { + // 시작 시간이 지났지만 아직 모집중이거나 모집완료 상태인 레슨들 조회 + List lessonsToStart = lessonRepository.findLessonsToStart(now); + + if (!lessonsToStart.isEmpty()) { + for (Lesson lesson : lessonsToStart) { + lesson.updateStatus(LessonStatus.IN_PROGRESS); + log.info("레슨 시작: ID={}, 이름={}, 시작시간={}", + lesson.getId(), lesson.getLessonName(), lesson.getStartAt()); + } + + lessonRepository.saveAll(lessonsToStart); + log.info("총 {}개의 레슨이 진행중 상태로 변경되었습니다.", lessonsToStart.size()); + } + } + + private void completeLessons(LocalDateTime now) { + // 종료 시간이 지났지만 아직 진행중 상태인 레슨들 조회 + List lessonsToComplete = lessonRepository.findLessonsToComplete(now); + + if (!lessonsToComplete.isEmpty()) { + for (Lesson lesson : lessonsToComplete) { + lesson.updateStatus(LessonStatus.COMPLETED); + log.info("레슨 완료: ID={}, 이름={}, 종료시간={}", + lesson.getId(), lesson.getLessonName(), lesson.getEndAt()); + } + + lessonRepository.saveAll(lessonsToComplete); + log.info("총 {}개의 레슨이 완료 상태로 변경되었습니다.", lessonsToComplete.size()); + } + } +} diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java index 84825e58..e9b3bbfc 100644 --- a/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonService.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.threestar.trainus.domain.lesson.teacher.constants.LessonConstants.Participants; +import com.threestar.trainus.domain.lesson.teacher.constants.LessonConstants.Time; import com.threestar.trainus.domain.lesson.teacher.dto.ApplicationProcessResponseDto; import com.threestar.trainus.domain.lesson.teacher.dto.CreatedLessonListResponseDto; import com.threestar.trainus.domain.lesson.teacher.dto.LessonApplicationListResponseDto; @@ -32,7 +34,6 @@ import com.threestar.trainus.domain.lesson.teacher.repository.LessonImageRepository; import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; import com.threestar.trainus.domain.user.entity.User; -import com.threestar.trainus.domain.user.repository.UserRepository; import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; @@ -46,117 +47,75 @@ @RequiredArgsConstructor public class AdminLessonService { - private final LessonRepository lessonRepository; // 레슨 DB 접근 - private final LessonImageRepository lessonImageRepository; // 레슨 이미지 DB 접근 - private final UserRepository userRepository; + private final LessonRepository lessonRepository; + private final LessonImageRepository lessonImageRepository; private final LessonApplicationRepository lessonApplicationRepository; private final UserService userService; + private final LessonCreationLimitService lessonCreationLimitService; - // 새로운 레슨을 생성하는 메서드 + //레슨 생성 public LessonResponseDto createLesson(LessonCreateRequestDto requestDto, Long userId) { - // User 조회 User user = userService.getUserById(userId); - validateLessonTimes(requestDto.startAt(), requestDto.endAt()); - - // 최대 참가 인원 검증 -> 100명이하로 제한 - if (requestDto.maxParticipants() > 100) { - throw new BusinessException(ErrorCode.LESSON_MAX_PARTICIPANTS_EXCEEDED); - } - - // 동일 레슨 중복 검증(동일한 강사가 같은 이름+시간으로 레슨 생성 차단) - boolean isDuplicate = lessonRepository.existsDuplicateLesson( - userId, - requestDto.lessonName(), - requestDto.startAt() - ); - if (isDuplicate) { - throw new BusinessException(ErrorCode.DUPLICATE_LESSON); - } + //레슨 생성 제한 확인 및 쿨타임 설정 + lessonCreationLimitService.checkAndSetCreationLimit(userId); - // 시간 겹침 검증(같은 강사가 동일 시간대에 여러 레슨 생성 차단) - boolean hasConflict = lessonRepository.hasTimeConflictLesson( - userId, - requestDto.startAt(), - requestDto.endAt() - ); - if (hasConflict) { - throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); - } + // 생성시에 필요한 검증을 진행 + validateLessonCreation(requestDto, userId); - // User 엔티티로 레슨 생성 + // 레슨 생성 및 저장 Lesson lesson = LessonMapper.toEntity(requestDto, user); - - // 레슨 저장 Lesson savedLesson = lessonRepository.save(lesson); - // 레슨 이미지 저장 + // 이미지 저장 List savedImages = saveLessonImages(savedLesson, requestDto.lessonImages()); - // 응답 DTO 반환 return LessonMapper.toResponseDto(savedLesson, savedImages); } - // 레슨 이미지들을 db에 저장하는 메서드 - private List saveLessonImages(Lesson lesson, List imageUrls) { - // 이미지가 없는 경우 빈 리스트 반환 - if (imageUrls == null || imageUrls.isEmpty()) { - return List.of(); - } - - List lessonImages = imageUrls.stream() - .map(url -> LessonImage.builder() - .lesson(lesson) - .imageUrl(url) - .build()) - .toList(); - - return lessonImageRepository.saveAll(lessonImages); + //레슨 삭제 + @Transactional + public void deleteLesson(Long lessonId, Long userId) { + Lesson lesson = validateLessonAccess(lessonId, userId); + validateLessonDeletion(lesson); + lesson.lessonDelete(); + lessonRepository.save(lesson); } - //레슨 삭제 + //레슨 수정 @Transactional - public void deleteLesson(Long lessonId, Long userId) { - // User 존재 확인 - User user = userService.getUserById(userId); - //레슨 조회 - Lesson lesson = findLessonById(lessonId); + public LessonUpdateResponseDto updateLesson(Long lessonId, LessonUpdateRequestDto requestDto, Long userId) { + Lesson lesson = validateLessonAccess(lessonId, userId); - //권한 확인 -> 레슨을 올린사람만 삭제가 가능하도록 - validateIsYourLesson(lesson, userId); + validateLessonIsRecruiting(lesson); + validateLessonTimeLimit(lesson); + validateUpdateRequest(requestDto); - //이미 삭제된 레슨인지 확인 - validateLessonNotDeleted(lesson); + boolean hasParticipants = hasApprovedParticipants(lesson); - lessonEditable(lesson); + // 수정 처리 + updateBasicInfo(lesson, requestDto); + updateMaxParticipants(lesson, requestDto, hasParticipants); + tryUpdateRestricted(lesson, requestDto, userId, lessonId, hasParticipants); - lesson.lessonDelete(); - lessonRepository.save(lesson); + // 저장 및 응답 + Lesson savedLesson = lessonRepository.save(lesson); + List updatedImages = updateLessonImages(savedLesson, requestDto.lessonImages()); + + return LessonMapper.toUpdateResponseDto(savedLesson, updatedImages); } //레슨 신청자 목록 조회 public LessonApplicationListResponseDto getLessonApplications( Long lessonId, int page, int limit, String status, Long userId) { - // 레슨 존재 및 권한 확인 Lesson lesson = validateLessonAccess(lessonId, userId); + ApplicationStatus applicationStatus = toApplicationStatus(status); + Pageable pageable = createPageable(page, limit, "createdAt", false); - // 상태 파라미터 검증 - ApplicationStatus applicationStatus = validateStatus(status); + Page applicationPage = getApplicationPage(lesson, status, applicationStatus, pageable); - // 페이징 설정 - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); - - // 신청자 목록 조회 - Page applicationPage; - if ("ALL".equals(status)) { - applicationPage = lessonApplicationRepository.findByLesson(lesson, pageable); - } else { - applicationPage = lessonApplicationRepository.findByLessonAndStatus(lesson, applicationStatus, pageable); - } - - //dto변환 return LessonApplicationMapper.toListResponseDto( applicationPage.getContent(), applicationPage.getTotalElements() @@ -168,99 +127,30 @@ public LessonApplicationListResponseDto getLessonApplications( public ApplicationProcessResponseDto processLessonApplication( Long lessonApplicationId, ApplicationAction action, Long userId) { - // 신청이 있는지 확인 - LessonApplication application = lessonApplicationRepository.findById(lessonApplicationId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND)); - - // 강사 권한 확인 -> 해당 레슨의 강사인지 확인 + LessonApplication application = findApplicationById(lessonApplicationId); Lesson lesson = application.getLesson(); + validateIsYourLesson(lesson, userId); + validatePending(application); - // 이미 처리된 신청인지 확인(대기중아니라면 -> 이미 승인이나 거절처리 된거니까) - if (!application.getStatus().equals(ApplicationStatus.PENDING)) { - throw new BusinessException(ErrorCode.LESSON_APPLICATION_ALREADY_PROCESSED); - } - - //승인/거절 처리 - if (action == ApplicationAction.APPROVED) { - validateCapacity(lesson); - application.approve(); - // 승인 시 레슨 참가자수 증가 - lesson.incrementParticipantCount(); - } else if (action == ApplicationAction.DENIED) { - application.deny(); - } + // 승인/거절 처리 + processApplication(application, lesson, action); LessonApplication savedApplication = lessonApplicationRepository.save(application); - // 응답 DTO 생성 - return ApplicationProcessResponseDto.builder() - .lessonApplicationId(savedApplication.getId()) - .userId(savedApplication.getUser().getId()) - .status(savedApplication.getStatus()) - .processedAt(savedApplication.getUpdatedAt()) - .build(); - } - - //레슨 수정 - @Transactional - public LessonUpdateResponseDto updateLesson(Long lessonId, LessonUpdateRequestDto requestDto, Long userId) { - // 레슨 존재 및 권한 확인 - Lesson lesson = validateLessonAccess(lessonId, userId); - - // 레슨이 모집중 상태인지 확인 - if (lesson.getStatus() != LessonStatus.RECRUITING) { - throw new BusinessException(ErrorCode.LESSON_NOT_EDITABLE); - } - - // 현재 참가자 수 확인 - int currentParticipants = lesson.getParticipantCount(); - boolean hasParticipants = currentParticipants > 0; - - // 수정할 필드가 있는지 확인 - if (!requestDto.hasBasicInfoChanges() && !requestDto.hasRestrictedChanges() - && requestDto.maxParticipants() == null) { - throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); - } - - // 기본 정보 수정 - updateBasicInfo(lesson, requestDto); - - // 최대 참가 인원 수정 -> 참가자 있을 때는 증가만 가능 - if (requestDto.maxParticipants() != null) { - lesson.updateMaxParticipants(requestDto.maxParticipants(), hasParticipants); - } - - // 제한된 필드 수정 -> 참가자 없을 때만 가능 - if (requestDto.hasRestrictedChanges()) { - if (hasParticipants) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } - updateRestrictedFields(lesson, requestDto, userId, lessonId); - } - - Lesson savedLesson = lessonRepository.save(lesson); - - List updatedImages = updateLessonImagesIfNeeded(savedLesson, requestDto.lessonImages()); - - return LessonMapper.toUpdateResponseDto(savedLesson, updatedImages); + return buildApplicationProcessResponse(savedApplication); } - //레슨 참가자 목록 조회(승인된 사람들만 있음) + //레슨 참가자 목록 조회 public ParticipantListResponseDto getLessonParticipants( Long lessonId, int page, int limit, Long userId) { - // 레슨 존재 및 권한 확인 Lesson lesson = validateLessonAccess(lessonId, userId); + Pageable pageable = createPageable(page, limit, "createdAt", true); - // 페이징 설정 - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").ascending()); - - // 승인처리(APPROVED)된 -> 레슨 신청자들만 조회 Page participantPage = lessonApplicationRepository - .findByLessonAndStatus(lesson, ApplicationStatus.APPROVED, pageable); + .findApprovedParticipantsWithUserAndProfile(lesson, pageable); - // dto 변환 return LessonParticipantMapper.toParticipantsResponseDto( participantPage.getContent(), participantPage.getTotalElements() @@ -271,245 +161,317 @@ public ParticipantListResponseDto getLessonParticipants( public CreatedLessonListResponseDto getCreatedLessons( Long userId, int page, int limit, String status) { - // User 존재 확인 User user = userService.getUserById(userId); + Pageable pageable = createPageable(page, limit, "createdAt", false); - // 페이징 설정 - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); + Page lessonPage = getLessonPage(userId, status, pageable); - // 레슨 상태에 따른 조회 - Page lessonPage; - if (status != null && !status.isEmpty()) { - // 레슨 상태에 따라서 필터링 - LessonStatus lessonStatus = validateLessonStatus(status); - lessonPage = lessonRepository.findByLessonLeaderAndStatusAndDeletedAtIsNull(userId, lessonStatus, pageable); - } else { - // 전체조회 - lessonPage = lessonRepository.findByLessonLeaderAndDeletedAtIsNull(userId, pageable); - } - - // dto 변환 return CreatedLessonMapper.toCreatedLessonListResponseDto( lessonPage.getContent(), lessonPage.getTotalElements() ); } - //시간 검증: 시작시간이 종료시간보다 앞에 있는지, 시작시간이 과거가 아닌지 + // 검증 메서드 + + //레슨 생성 시 검증 + private void validateLessonCreation(LessonCreateRequestDto requestDto, Long userId) { + validateLessonTimes(requestDto.startAt(), requestDto.endAt()); + validateMaxParticipantsByType(requestDto.maxParticipants(), requestDto.openRun()); + validateDuplicateLesson(userId, requestDto.lessonName(), requestDto.startAt()); + validateTimeConflict(userId, requestDto.startAt(), requestDto.endAt()); + } + + //레슨 삭제 검증 + private void validateLessonDeletion(Lesson lesson) { + validateLessonIsRecruiting(lesson); + + if (hasApprovedParticipants(lesson)) { + throw new BusinessException(ErrorCode.LESSON_DELETE_HAS_PARTICIPANTS); + } + + validateLessonTimeLimit(lesson); + } + + //레슨 접근 권한 검증 + private Lesson validateLessonAccess(Long lessonId, Long userId) { + userService.getUserById(userId); + Lesson lesson = findLessonById(lessonId); + validateLessonNotDeleted(lesson); + validateIsYourLesson(lesson, userId); + return lesson; + } + + //모집중 상태 검증 + private void validateLessonIsRecruiting(Lesson lesson) { + if (lesson.getStatus() != LessonStatus.RECRUITING) { + throw new BusinessException(ErrorCode.LESSON_NOT_EDITABLE); + } + } + + //수정/삭제는 12시 전만 가능하도록 + private void validateLessonTimeLimit(Lesson lesson) { + LocalDateTime timeLimit = lesson.getStartAt().minusHours(Time.EDIT_DELETE_LIMIT_HOURS); + if (LocalDateTime.now().isAfter(timeLimit)) { + throw new BusinessException(ErrorCode.LESSON_TIME_LIMIT_EXCEEDED); + } + } + + //승인된 참가자 존재 여부 확인 + private boolean hasApprovedParticipants(Lesson lesson) { + return lessonApplicationRepository + .countByLessonAndStatus(lesson, ApplicationStatus.APPROVED) > 0; + } + + //강사 권한 검증 + private void validateIsYourLesson(Lesson lesson, Long userId) { + if (!lesson.getLessonLeader().equals(userId)) { + throw new BusinessException(ErrorCode.ACCESS_FORBIDDEN); + } + } + + //레슨 삭제 여부 검증 + private void validateLessonNotDeleted(Lesson lesson) { + if (lesson.isDeleted()) { + throw new BusinessException(ErrorCode.LESSON_NOT_FOUND); + } + } + + //시간 검증 private void validateLessonTimes(LocalDateTime startAt, LocalDateTime endAt) { LocalDateTime now = LocalDateTime.now(); - // 시작시간이 현재시간보다 과거인지 확인 if (startAt.isBefore(now)) { throw new BusinessException(ErrorCode.LESSON_START_TIME_INVALID); } - // 종료시간이 시작시간보다 이전인지 확인 if (endAt.isBefore(startAt) || endAt.isEqual(startAt)) { throw new BusinessException(ErrorCode.LESSON_END_TIME_BEFORE_START); } } - //정원 초과 검증-> 승인 시 maxParticipants 초과하지 않는지 - private void validateCapacity(Lesson lesson) { - if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) { + //참여방식에 따른 최대 인원 검증 + private void validateMaxParticipantsByType(Integer maxParticipants, Boolean openRun) { + int maxLimit = openRun ? Participants.MAX_OPEN_RUN_PARTICIPANTS : Participants.MAX_NORMAL_PARTICIPANTS; + if (maxParticipants > maxLimit) { throw new BusinessException(ErrorCode.LESSON_MAX_PARTICIPANTS_EXCEEDED); } } - //레슨 상태 검증: 이미 시작되거나 완료된 레슨 수정/삭제 방지 - private void lessonEditable(Lesson lesson) { - // 진행중이거나 완료된 레슨은 수정/삭제 불가 - if (lesson.getStatus() == LessonStatus.IN_PROGRESS || lesson.getStatus() == LessonStatus.COMPLETED) { - throw new BusinessException(ErrorCode.INVALID_LESSON_DATE); + //중복 레슨 검증 + private void validateDuplicateLesson(Long userId, String lessonName, LocalDateTime startAt) { + boolean isDuplicate = lessonRepository.existsDuplicateLesson(userId, lessonName, startAt); + if (isDuplicate) { + throw new BusinessException(ErrorCode.DUPLICATE_LESSON); } + } - // 레슨 시작 시간이 지났는지도 확인 - if (lesson.getStartAt().isBefore(LocalDateTime.now())) { - throw new BusinessException(ErrorCode.LESSON_START_TIME_INVALID); + //시간 겹침 검증 + private void validateTimeConflict(Long userId, LocalDateTime startAt, LocalDateTime endAt) { + boolean hasConflict = lessonRepository.hasTimeConflictLesson(userId, startAt, endAt); + if (hasConflict) { + throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); } } - //레슨 조회 및 검증 - public Lesson findLessonById(Long lessonId) { - return lessonRepository.findById(lessonId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + //정원 초과 검증 + private void validateCapacity(Lesson lesson) { + if (lesson.getParticipantCount() >= lesson.getMaxParticipants()) { + throw new BusinessException(ErrorCode.LESSON_MAX_PARTICIPANTS_EXCEEDED); + } } - public Lesson findLessonByIdWithLock(Long lessonId) { - return lessonRepository.findByIdWithLock(lessonId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + //수정 요청 검증 + private void validateUpdateRequest(LessonUpdateRequestDto requestDto) { + if (!requestDto.hasBasicInfoChanges() && !requestDto.hasRestrictedChanges() + && requestDto.maxParticipants() == null) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } } - //레슨 application조회 및 검증 - public LessonApplication findApplicationById(Long applicationId) { - return lessonApplicationRepository.findById(applicationId) - .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND)); + //신청 처리 가능 여부 검증 + private void validatePending(LessonApplication application) { + if (!application.getStatus().equals(ApplicationStatus.PENDING)) { + throw new BusinessException(ErrorCode.LESSON_APPLICATION_ALREADY_PROCESSED); + } } - //강사 권한 검증 - public void validateIsYourLesson(Lesson lesson, Long userId) { - if (!lesson.getLessonLeader().equals(userId)) { - throw new BusinessException(ErrorCode.LESSON_ACCESS_FORBIDDEN); + //기본 정보 수정 + private void updateBasicInfo(Lesson lesson, LessonUpdateRequestDto requestDto) { + lesson.updateLessonName(requestDto.lessonName()); + lesson.updateDescription(requestDto.description()); + } + + //최대 참가 인원 수정 + private void updateMaxParticipants(Lesson lesson, LessonUpdateRequestDto requestDto, + boolean hasParticipants) { + if (requestDto.maxParticipants() != null) { + lesson.updateMaxParticipants(requestDto.maxParticipants(), hasParticipants); } } - //레슨 삭제 여부 검증 - public void validateLessonNotDeleted(Lesson lesson) { - if (lesson.isDeleted()) { - throw new BusinessException(ErrorCode.LESSON_NOT_FOUND); + //제한된 필드 수정 + private void tryUpdateRestricted(Lesson lesson, LessonUpdateRequestDto requestDto, + Long userId, Long lessonId, boolean hasParticipants) { + + if (!requestDto.hasRestrictedChanges()) { + return; //제한된 필드 수정 요청이 없다면 아무것도 안함 } + + if (hasParticipants) { + throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + } + + updateRestrictedFields(lesson, requestDto, userId, lessonId); } - //레슨 접근 권한 검증 -> 올린사람(강사)가 맞는지 체크 - private Lesson validateLessonAccess(Long lessonId, Long userId) { - // User 존재 확인 - User user = userService.getUserById(userId); + //제한된 필드 업데이트 실행 + private void updateRestrictedFields(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, Long lessonId) { + validateTimeChanges(lesson, requestDto, userId, lessonId); - // 레슨 존재하는지 확인 - Lesson lesson = findLessonById(lessonId); + lesson.updateCategory(requestDto.category()); + lesson.updatePrice(requestDto.price()); + lesson.updateLessonTime(requestDto.startAt(), requestDto.endAt()); + lesson.updateOpenTime(requestDto.openTime()); + lesson.updateOpenRun(requestDto.openRun()); + lesson.updateLocation(requestDto.city(), requestDto.district(), requestDto.dong()); + lesson.updateAddressDetail(requestDto.addressDetail()); + } - // 삭제된 레슨 확인 - validateLessonNotDeleted(lesson); + //시간 변경 검증 + private void validateTimeChanges(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, + Long lessonId) { + if (!requestDto.hasTimeChanges()) { + return; + } - // 강사 본인 확인 - validateIsYourLesson(lesson, userId); + LocalDateTime newStartAt = requestDto.startAt() != null ? requestDto.startAt() : lesson.getStartAt(); + LocalDateTime newEndAt = requestDto.endAt() != null ? requestDto.endAt() : lesson.getEndAt(); - return lesson; + validateLessonTimes(newStartAt, newEndAt); + + boolean hasConflict = lessonRepository.hasTimeConflictForUpdate(userId, newStartAt, newEndAt, lessonId); + if (hasConflict) { + throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); + } } - //레슨 상태 검증 - private ApplicationStatus validateStatus(String status) { - if ("ALL".equals(status)) { - return null; // ALL인 경우 null 반환해서 필터링 안함 + //신청 처리 + private void processApplication(LessonApplication application, Lesson lesson, ApplicationAction action) { + if (action == ApplicationAction.APPROVED) { + validateCapacity(lesson); + application.approve(); + lesson.incrementParticipantCount(); + } else if (action == ApplicationAction.DENIED) { + application.deny(); } + } + //페이지 객체 생성 + private Pageable createPageable(int page, int limit, String sortBy, boolean ascending) { + Sort sort = ascending ? Sort.by(sortBy).ascending() : Sort.by(sortBy).descending(); + return PageRequest.of(page - 1, limit, sort); + } + + //신청 상태 파싱 + private ApplicationStatus toApplicationStatus(String status) { + if ("ALL".equals(status)) { + return null; + } try { return ApplicationStatus.valueOf(status); } catch (IllegalArgumentException e) { throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS); } } - //레슨 신청 상태 검증 - private LessonStatus validateLessonStatus(String status) { + //레슨 신청 상태 검증 + private LessonStatus toLessonStatus(String status) { try { return LessonStatus.valueOf(status); } catch (IllegalArgumentException e) { throw new BusinessException(ErrorCode.INVALID_LESSON_STATUS); } } - //참가자가 있을때 수정 검증 - - private void validatePeopleInLessonUpdate(Lesson lesson, LessonUpdateRequestDto requestDto) { - // 카테고리 변경 불가 - if (!lesson.getCategory().equals(requestDto.category())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } - // 가격 변경 불가 - if (!lesson.getPrice().equals(requestDto.price())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } + //신청 페이지 조회 + private Page getApplicationPage(Lesson lesson, String status, + ApplicationStatus applicationStatus, Pageable pageable) { - // 참여방식 변경 불가 - if (!lesson.getOpenRun().equals(requestDto.openRun())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } - - // 레슨 시간 변경 불가 - if (!lesson.getStartAt().equals(requestDto.startAt()) || !lesson.getEndAt().equals(requestDto.endAt())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); - } - - // 지역 정보 변경 불가 - if (!lesson.getCity().equals(requestDto.city()) || !lesson.getDistrict().equals(requestDto.district()) - || !lesson.getDong().equals(requestDto.dong())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + if ("ALL".equals(status)) { + return lessonApplicationRepository.findByLessonWithUserAndProfile(lesson, pageable); + } else { + return lessonApplicationRepository.findByLessonAndStatusWithUserAndProfile( + lesson, applicationStatus, pageable); } + } - // 상세주소 변경 불가 - if (!lesson.getAddressDetail().equals(requestDto.addressDetail())) { - throw new BusinessException(ErrorCode.LESSON_PARTICIPANTS_EXIST_RESTRICTION); + //레슨 페이지 조회 + private Page getLessonPage(Long userId, String status, Pageable pageable) { + if (status != null && !status.isEmpty()) { + LessonStatus lessonStatus = toLessonStatus(status); + return lessonRepository.findByLessonLeaderAndStatusAndDeletedAtIsNull(userId, lessonStatus, pageable); + } else { + return lessonRepository.findByLessonLeaderAndDeletedAtIsNull(userId, pageable); } } - //레슨 이미지 업데이트 - private List updateLessonImages(Lesson lesson, List imageUrls) { - // 기존 이미지 삭제 - List existingImages = lessonImageRepository.findByLesson(lesson); - lessonImageRepository.deleteAll(existingImages); - - // 새 이미지 저장 + //레슨 이미지 저장 + private List saveLessonImages(Lesson lesson, List imageUrls) { if (imageUrls == null || imageUrls.isEmpty()) { return List.of(); } - List newImages = imageUrls.stream() + List lessonImages = imageUrls.stream() .map(url -> LessonImage.builder() .lesson(lesson) .imageUrl(url) .build()) .toList(); - return lessonImageRepository.saveAll(newImages); - } - //기본 정보 수정 - - private void updateBasicInfo(Lesson lesson, LessonUpdateRequestDto requestDto) { - lesson.updateLessonName(requestDto.lessonName()); - lesson.updateDescription(requestDto.description()); - } - //제한되어 있는 필드 수정 - - private void updateRestrictedFields(Lesson lesson, LessonUpdateRequestDto requestDto, Long userId, Long lessonId) { - // 시간 관련 검증 - if (requestDto.hasTimeChanges()) { - LocalDateTime newStartAt = requestDto.startAt() != null ? requestDto.startAt() : lesson.getStartAt(); - LocalDateTime newEndAt = requestDto.endAt() != null ? requestDto.endAt() : lesson.getEndAt(); - - // 시간 검증 - validateLessonTimes(newStartAt, newEndAt); - - // 시간 겹침 검증 - boolean hasConflict = lessonRepository.hasTimeConflictForUpdate( - userId, newStartAt, newEndAt, lessonId - ); - if (hasConflict) { - throw new BusinessException(ErrorCode.LESSON_TIME_OVERLAP); - } - } - - lesson.updateCategory(requestDto.category()); - lesson.updatePrice(requestDto.price()); - lesson.updateLessonTime(requestDto.startAt(), requestDto.endAt()); - lesson.updateOpenTime(requestDto.openTime()); - lesson.updateOpenRun(requestDto.openRun()); - lesson.updateLocation(requestDto.city(), requestDto.district(), requestDto.dong()); - lesson.updateAddressDetail(requestDto.addressDetail()); + return lessonImageRepository.saveAll(lessonImages); } - //레슨 이미지 수정 - private List updateLessonImagesIfNeeded(Lesson lesson, List newImageUrls) { + //레슨 이미지 업데이트 + private List updateLessonImages(Lesson lesson, List newImageUrls) { if (newImageUrls != null) { - // 기존 이미지 삭제 List existingImages = lessonImageRepository.findByLesson(lesson); lessonImageRepository.deleteAll(existingImages); - // 새 이미지 저장 if (!newImageUrls.isEmpty()) { - List newImages = newImageUrls.stream() - .map(url -> LessonImage.builder() - .lesson(lesson) - .imageUrl(url) - .build()) - .toList(); - return lessonImageRepository.saveAll(newImages); + return saveLessonImages(lesson, newImageUrls); } return List.of(); } else { - // 이미지 수정 요청 없으면 -> 기존 이미지 유지 return lessonImageRepository.findByLesson(lesson); } } + + //신청처리 응답dto + private ApplicationProcessResponseDto buildApplicationProcessResponse(LessonApplication application) { + return ApplicationProcessResponseDto.builder() + .lessonApplicationId(application.getId()) + .userId(application.getUser().getId()) + .status(application.getStatus()) + .processedAt(application.getUpdatedAt()) + .build(); + } + + //레슨 조회 + public Lesson findLessonById(Long lessonId) { + return lessonRepository.findById(lessonId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + } + + // develop 브랜치에서 추가된 메서드 - Lock 기능 추가 + public Lesson findLessonByIdWithLock(Long lessonId) { + return lessonRepository.findByIdWithLock(lessonId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_NOT_FOUND)); + } + + //레슨 신청 조회 + public LessonApplication findApplicationById(Long applicationId) { + return lessonApplicationRepository.findById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.LESSON_APPLICATION_NOT_FOUND)); + } } diff --git a/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitService.java b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitService.java new file mode 100644 index 00000000..457fcf3e --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitService.java @@ -0,0 +1,51 @@ +package com.threestar.trainus.domain.lesson.teacher.service; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 레슨 생성 제한 서비스 + * Redis를 사용해 강사의 레슨 생성에 쿨타임을 적용 ->1분 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LessonCreationLimitService { + + private final RedisTemplate redisTemplate; + + // 레슨 생성 쿨타임 (1분) + private static final Duration CREATION_COOLTIME = Duration.ofMinutes(1); + + //redis 키 접두사 + private static final String LESSON_CREATION_KEY_PREFIX = "lesson_creation_limit:"; + + //레슨 생성 가능 여부 확인 + 쿨타임 설정 + public void checkAndSetCreationLimit(Long userId) { + String key = generateRedisKey(userId); + + // 이미 쿨타임이 설정되어 있는지 확인 + if (redisTemplate.hasKey(key)) { + Long remainingTtl = redisTemplate.getExpire(key); + log.info("레슨 생성 제한 - 사용자 ID: {}, 남은 시간: {}초", userId, remainingTtl); + throw new BusinessException(ErrorCode.LESSON_CREATION_TOO_FREQUENT); + } + + // 쿨타임 설정 + redisTemplate.opsForValue().set(key, "restricted", CREATION_COOLTIME); + log.info("레슨 생성 쿨타임 설정 - 사용자 ID: {}, 지속시간: {}분", userId, CREATION_COOLTIME.toMinutes()); + } + + // Redis 키를 생성 + private String generateRedisKey(Long userId) { + return LESSON_CREATION_KEY_PREFIX + userId; + } +} diff --git a/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java b/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java index 319e1b67..dcc0befa 100644 --- a/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java +++ b/src/main/java/com/threestar/trainus/domain/profile/service/ProfileLessonService.java @@ -43,7 +43,7 @@ public ProfileCreatedLessonListResponseDto getUserCreatedLessons( // 상태에 따라 조회 가능 lessonPage = lessonRepository.findByLessonLeaderAndStatusAndDeletedAtIsNull(userId, status, pageable); } else { - // 삭제되지 않은 레슨만 조회 + // 모든 상태 조회 lessonPage = lessonRepository.findByLessonLeaderAndDeletedAtIsNull(userId, pageable); } diff --git a/src/main/java/com/threestar/trainus/domain/user/service/UserService.java b/src/main/java/com/threestar/trainus/domain/user/service/UserService.java index 6465fe56..3f7fa275 100644 --- a/src/main/java/com/threestar/trainus/domain/user/service/UserService.java +++ b/src/main/java/com/threestar/trainus/domain/user/service/UserService.java @@ -107,7 +107,7 @@ public void validateUserExists(Long userId) { public void validateAdminRole(Long userId) { User user = getUserById(userId); if (user.getRole() != UserRole.ADMIN) { - throw new BusinessException(ErrorCode.AUTHENTICATION_REQUIRED); + throw new BusinessException(ErrorCode.ACCESS_FORBIDDEN); } } @@ -116,7 +116,7 @@ public void validateAdminRole(Long userId) { public User getAdminUser(Long userId) { User user = getUserById(userId); if (user.getRole() != UserRole.ADMIN) { - throw new BusinessException(ErrorCode.AUTHENTICATION_REQUIRED); + throw new BusinessException(ErrorCode.ACCESS_FORBIDDEN); } return user; } diff --git a/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java b/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java index 8cda2818..c2999d0a 100644 --- a/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java +++ b/src/main/java/com/threestar/trainus/global/exception/domain/ErrorCode.java @@ -18,6 +18,9 @@ public enum ErrorCode { // 401 AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "인증이 필요한 요청입니다. 로그인 해주세요."), + //403 + ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다."), + // 404 LESSON_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 레슨을 찾을 수 없습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 사용자를 찾을 수 없습니다."), @@ -33,7 +36,8 @@ public enum ErrorCode { COUPON_EXPIRED(HttpStatus.BAD_REQUEST, "쿠폰 발급 기간이 종료되었습니다."), COUPON_NOT_YET_OPEN(HttpStatus.BAD_REQUEST, "아직 발급이 시작되지 않은 쿠폰입니다."), COUPON_BE_EXHAUSTED(HttpStatus.BAD_REQUEST, "수량이 소진된 쿠폰입니다."), - + COUPON_CANNOT_DELETE_ISSUED(HttpStatus.BAD_REQUEST, "발급된 쿠폰이 있어 삭제할 수 없습니다. 상태를 비활성화로 변경해주세요."), + COUPON_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 쿠폰입니다."), //404 COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 쿠폰을 찾을 수 없습니다."), @@ -70,6 +74,10 @@ public enum ErrorCode { LESSON_NOT_EDITABLE(HttpStatus.BAD_REQUEST, "수정할 수 없는 상태의 레슨입니다. 모집중 상태의 레슨만 수정 가능합니다."), LESSON_MAX_PARTICIPANTS_CANNOT_DECREASE(HttpStatus.BAD_REQUEST, "참가자가 있는 레슨은 최대 참가 인원을 줄일 수 없습니다."), LESSON_PARTICIPANTS_EXIST_RESTRICTION(HttpStatus.BAD_REQUEST, "참가자가 있어 해당 필드는 수정할 수 없습니다."), + LESSON_DELETE_STATUS_INVALID(HttpStatus.BAD_REQUEST, "모집중인 레슨만 삭제할 수 있습니다."), + LESSON_DELETE_HAS_PARTICIPANTS(HttpStatus.BAD_REQUEST, "참가자가 있는 레슨은 삭제할 수 없습니다."), + LESSON_TIME_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "레슨 시작 12시간 전이므로 수정/삭제할 수 없습니다."), + LESSON_CREATION_TOO_FREQUENT(HttpStatus.TOO_MANY_REQUESTS, "레슨 생성은 1분에 1번만 가능합니다. 잠시 후 다시 시도해주세요."), // 403 Forbidden LESSON_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "레슨 삭제 권한이 없습니다. 강사만 삭제할 수 있습니다."), diff --git a/src/test/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusSchedulerTest.java b/src/test/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusSchedulerTest.java new file mode 100644 index 00000000..8c673d90 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/coupon/admin/scheduler/CouponStatusSchedulerTest.java @@ -0,0 +1,122 @@ +package com.threestar.trainus.domain.coupon.admin.scheduler; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.entity.UserCoupon; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.user.entity.User; + +@ExtendWith(MockitoExtension.class) +class CouponStatusSchedulerTest { + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @InjectMocks + private CouponStatusScheduler couponStatusScheduler; + + @Test + @DisplayName("비활성화된 쿠폰을 활성화하는 스케줄러 테스트") + void updateCouponStatus_ActivateCoupons() { + LocalDateTime now = LocalDateTime.now(); + + Coupon inactiveCoupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.INACTIVE) + .category(CouponCategory.NORMAL) + .openAt(now.minusHours(1)) // 1시간 전에 오픈 + .closeAt(now.plusDays(1)) // 내일 마감 + .build(); + + given(couponRepository.findInactiveCouponsToActivate(any(LocalDateTime.class))) + .willReturn(List.of(inactiveCoupon)); + given(couponRepository.findActiveCouponsToDeactivate(any(LocalDateTime.class))) + .willReturn(List.of()); + + couponStatusScheduler.updateCouponStatus(); + + verify(couponRepository).findInactiveCouponsToActivate(any(LocalDateTime.class)); + verify(couponRepository).findActiveCouponsToDeactivate(any(LocalDateTime.class)); + verify(couponRepository).saveAll(anyList()); + } + + @Test + @DisplayName("활성화된 쿠폰을 비활성화하는 스케줄러 테스트") + void updateCouponStatus_DeactivateCoupons() { + LocalDateTime now = LocalDateTime.now(); + + Coupon activeCoupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .category(CouponCategory.NORMAL) + .openAt(now.minusDays(7)) // 7일 전에 오픈 + .closeAt(now.minusHours(1)) // 1시간 전에 마감 + .build(); + + given(couponRepository.findInactiveCouponsToActivate(any(LocalDateTime.class))) + .willReturn(List.of()); + given(couponRepository.findActiveCouponsToDeactivate(any(LocalDateTime.class))) + .willReturn(List.of(activeCoupon)); + + couponStatusScheduler.updateCouponStatus(); + + verify(couponRepository).findInactiveCouponsToActivate(any(LocalDateTime.class)); + verify(couponRepository).findActiveCouponsToDeactivate(any(LocalDateTime.class)); + verify(couponRepository).saveAll(anyList()); + } + + @Test + @DisplayName("만료된 유저쿠폰을 처리하는 스케줄러 테스트") + void updateUserCouponStatus_ExpireCoupons() { + LocalDateTime now = LocalDateTime.now(); + + User user = User.builder().id(1L).build(); + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .category(CouponCategory.NORMAL) + .build(); + + UserCoupon expiredUserCoupon = new UserCoupon(user, coupon, now.minusHours(1)); // 1시간 전에 만료 + + given(userCouponRepository.findActiveUserCouponsToExpire(any(LocalDateTime.class))) + .willReturn(List.of(expiredUserCoupon)); + + couponStatusScheduler.updateUserCouponStatus(); + + verify(userCouponRepository).findActiveUserCouponsToExpire(any(LocalDateTime.class)); + verify(userCouponRepository).saveAll(anyList()); + } + + @Test + @DisplayName("처리할 쿠폰이 없는 경우 스케줄러 테스트") + void updateCouponStatus_NoCouponsToProcess() { + given(couponRepository.findInactiveCouponsToActivate(any(LocalDateTime.class))) + .willReturn(List.of()); + given(couponRepository.findActiveCouponsToDeactivate(any(LocalDateTime.class))) + .willReturn(List.of()); + + couponStatusScheduler.updateCouponStatus(); + + verify(couponRepository).findInactiveCouponsToActivate(any(LocalDateTime.class)); + verify(couponRepository).findActiveCouponsToDeactivate(any(LocalDateTime.class)); + verify(couponRepository, never()).saveAll(anyList()); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponServiceTest.java b/src/test/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponServiceTest.java new file mode 100644 index 00000000..17728098 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponServiceTest.java @@ -0,0 +1,335 @@ +package com.threestar.trainus.domain.coupon.admin.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateRequestDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateResponseDto; +import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; +import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; +import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +class AdminCouponServiceTest { + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @Mock + private UserService userService; + + @InjectMocks + private AdminCouponService adminCouponService; + + @Test + @DisplayName("정상적인 쿠폰 생성 테스트") + void createCoupon_Success() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + CouponCreateRequestDto request = new CouponCreateRequestDto( + "테스트 쿠폰", + now.plusDays(30), + "5000", + 10000, + CouponStatus.ACTIVE, + 100, + CouponCategory.NORMAL, + now, + now.plusDays(7) + ); + + Coupon savedCoupon = Coupon.builder() + .name("테스트 쿠폰") + .expirationDate(now.plusDays(30)) + .discountPrice("5000") + .minOrderPrice(10000) + .status(CouponStatus.ACTIVE) + .quantity(100) + .category(CouponCategory.NORMAL) + .openAt(now) + .closeAt(now.plusDays(7)) + .build(); + + given(couponRepository.save(any(Coupon.class))).willReturn(savedCoupon); + + CouponCreateResponseDto response = adminCouponService.createCoupon(request, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + verify(userService).validateAdminRole(userId); + verify(couponRepository).save(any(Coupon.class)); + } + + @Test + @DisplayName("할인가격 형식이 잘못된 경우 예외 발생") + void createCoupon_InvalidDiscountPrice() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + CouponCreateRequestDto request = new CouponCreateRequestDto( + "테스트 쿠폰", + now.plusDays(30), + "잘못된형식", // 잘못된 할인가격 + 10000, + CouponStatus.ACTIVE, + 100, + CouponCategory.NORMAL, + now, + now.plusDays(7) + ); + + assertThatThrownBy(() -> adminCouponService.createCoupon(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("쿠폰 상세 조회 성공") + void getCouponDetail_Success() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .discountPrice("5000") + .minOrderPrice(10000) + .status(CouponStatus.ACTIVE) + .quantity(100) + .category(CouponCategory.NORMAL) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(10L); + + var response = adminCouponService.getCouponDetail(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + assertThat(response.issuedCount()).isEqualTo(10); + verify(userService).validateAdminRole(userId); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 조회시 예외 발생") + void getCouponDetail_NotFound() { + Long couponId = 999L; + Long userId = 1L; + + given(couponRepository.findById(couponId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCouponService.getCouponDetail(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("쿠폰 삭제 성공") + void deleteCoupon_Success() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(0L); + given(couponRepository.save(any(Coupon.class))).willReturn(coupon); + + var response = adminCouponService.deleteCoupon(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + verify(userService).validateAdminRole(userId); + verify(couponRepository).save(coupon); + } + + @Test + @DisplayName("이미 삭제된 쿠폰 삭제시 예외 발생") + void deleteCoupon_AlreadyDeleted() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .build(); + + coupon.markAsDeleted(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + } + + @Test + @DisplayName("발급되지 않은 쿠폰 삭제 성공") + void deleteCoupon_Success_NoIssuedCoupons() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("5000") + .minOrderPrice(10000) + .category(CouponCategory.NORMAL) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(0L); // 발급된 쿠폰 없음 + given(couponRepository.save(any(Coupon.class))).willReturn(coupon); + + var response = adminCouponService.deleteCoupon(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("테스트 쿠폰"); + verify(userService).validateAdminRole(userId); + verify(couponRepository).save(coupon); + assertThat(coupon.isDeleted()).isTrue(); // 삭제 상태 확인 + } + + @Test + @DisplayName("발급된 쿠폰이 있는 경우 삭제 실패") + void deleteCoupon_Fail_HasIssuedCoupons() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("5000") + .minOrderPrice(10000) + .category(CouponCategory.NORMAL) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(5L); // 5명이 발급받음 + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COUPON_CANNOT_DELETE_ISSUED); + + verify(userService).validateAdminRole(userId); + verify(couponRepository, never()).save(any(Coupon.class)); // 저장되지 않음 + assertThat(coupon.isDeleted()).isFalse(); // 삭제되지 않음 + } + + @Test + @DisplayName("이미 삭제된 쿠폰 삭제 시도 시 실패") + void deleteCoupon_Fail_AlreadyDeleted() { + Long couponId = 1L; + Long userId = 1L; + + Coupon coupon = Coupon.builder() + .name("테스트 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("5000") + .minOrderPrice(10000) + .category(CouponCategory.NORMAL) + .build(); + + coupon.markAsDeleted(); // 미리 삭제 상태로 만듦 + + given(couponRepository.findById(couponId)).willReturn(Optional.of(coupon)); + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + + verify(userService).validateAdminRole(userId); + verify(userCouponRepository, never()).countByCouponId(anyLong()); // 발급 수량 조회하지 않음 + } + + @Test + @DisplayName("존재하지 않는 쿠폰 삭제 시도 시 실패") + void deleteCoupon_Fail_CouponNotFound() { + Long couponId = 999L; + Long userId = 1L; + + given(couponRepository.findById(couponId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_REQUEST_DATA); + + verify(userService).validateAdminRole(userId); + verify(userCouponRepository, never()).countByCouponId(anyLong()); + } + + @Test + @DisplayName("선착순 쿠폰 - 발급되지 않은 경우 삭제 성공") + void deleteCoupon_Success_OpenRunCoupon_NoIssued() { + Long couponId = 1L; + Long userId = 1L; + + Coupon openRunCoupon = Coupon.builder() + .name("선착순 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("10%") + .minOrderPrice(50000) + .quantity(100) + .category(CouponCategory.OPEN_RUN) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(openRunCoupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(0L); // 아무도 발급받지 않음 + given(couponRepository.save(any(Coupon.class))).willReturn(openRunCoupon); + + var response = adminCouponService.deleteCoupon(couponId, userId); + + assertThat(response).isNotNull(); + assertThat(response.couponName()).isEqualTo("선착순 쿠폰"); + verify(couponRepository).save(openRunCoupon); + assertThat(openRunCoupon.isDeleted()).isTrue(); + } + + @Test + @DisplayName("선착순 쿠폰 - 발급된 경우 삭제 실패") + void deleteCoupon_Fail_OpenRunCoupon_HasIssued() { + Long couponId = 1L; + Long userId = 1L; + + Coupon openRunCoupon = Coupon.builder() + .name("선착순 쿠폰") + .status(CouponStatus.ACTIVE) + .discountPrice("10%") + .minOrderPrice(50000) + .quantity(100) + .category(CouponCategory.OPEN_RUN) + .build(); + + given(couponRepository.findById(couponId)).willReturn(Optional.of(openRunCoupon)); + given(userCouponRepository.countByCouponId(couponId)).willReturn(50L); // 50명이 발급받음 + + assertThatThrownBy(() -> adminCouponService.deleteCoupon(couponId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.COUPON_CANNOT_DELETE_ISSUED); + + verify(couponRepository, never()).save(any(Coupon.class)); + assertThat(openRunCoupon.isDeleted()).isFalse(); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusSchedulerTest.java b/src/test/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusSchedulerTest.java new file mode 100644 index 00000000..15d862e5 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/teacher/scheduler/LessonStatusSchedulerTest.java @@ -0,0 +1,157 @@ +package com.threestar.trainus.domain.lesson.teacher.scheduler; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.lesson.teacher.entity.Category; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; + +@ExtendWith(MockitoExtension.class) +class LessonStatusSchedulerTest { + @Mock + private LessonRepository lessonRepository; + + @InjectMocks + private LessonStatusScheduler lessonStatusScheduler; + + @Test + @DisplayName("시작할 레슨들을 진행중으로 변경하는 스케줄러 테스트") + void updateLessonStatus_StartLessons() { + LocalDateTime now = LocalDateTime.now(); + + Lesson lessonToStart = Lesson.builder() + .lessonLeader(1L) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.minusMinutes(30)) // 30분 전에 시작 + .endAt(now.plusHours(1)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of(lessonToStart)); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of()); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository).saveAll(anyList()); + } + + @Test + @DisplayName("완료할 레슨들을 완료 상태로 변경하는 스케줄러 테스트") + void updateLessonStatus_CompleteLessons() { + LocalDateTime now = LocalDateTime.now(); + + Lesson lessonToComplete = Lesson.builder() + .lessonLeader(1L) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.minusHours(2)) // 2시간 전에 시작 + .endAt(now.minusMinutes(30)) // 30분 전에 종료 + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of()); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of(lessonToComplete)); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository).saveAll(anyList()); + } + + @Test + @DisplayName("처리할 레슨이 없는 경우 스케줄러 테스트") + void updateLessonStatus_NoLessonsToProcess() { + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of()); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of()); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("시작과 완료를 동시에 처리하는 스케줄러 테스트") + void updateLessonStatus_StartAndCompleteLessons() { + LocalDateTime now = LocalDateTime.now(); + + Lesson lessonToStart = Lesson.builder() + .lessonLeader(1L) + .lessonName("시작할 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.minusMinutes(10)) // 10분 전에 시작 + .endAt(now.plusHours(1)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + Lesson lessonToComplete = Lesson.builder() + .lessonLeader(2L) + .lessonName("완료할 레슨") + .description("테스트 설명") + .category(Category.YOGA) + .price(25000) + .maxParticipants(8) + .startAt(now.minusHours(2)) + .endAt(now.minusMinutes(10)) // 10분 전에 종료 + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(lessonRepository.findLessonsToStart(any(LocalDateTime.class))) + .willReturn(List.of(lessonToStart)); + given(lessonRepository.findLessonsToComplete(any(LocalDateTime.class))) + .willReturn(List.of(lessonToComplete)); + + lessonStatusScheduler.updateLessonStatus(); + + verify(lessonRepository).findLessonsToStart(any(LocalDateTime.class)); + verify(lessonRepository).findLessonsToComplete(any(LocalDateTime.class)); + verify(lessonRepository, times(2)).saveAll(anyList()); // 시작과 완료 각각 한 번씩 + } +} diff --git a/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonServiceTest.java b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonServiceTest.java new file mode 100644 index 00000000..fbdce5c9 --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/AdminLessonServiceTest.java @@ -0,0 +1,316 @@ +package com.threestar.trainus.domain.lesson.teacher.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.threestar.trainus.domain.lesson.teacher.dto.LessonCreateRequestDto; +import com.threestar.trainus.domain.lesson.teacher.dto.LessonResponseDto; +import com.threestar.trainus.domain.lesson.teacher.entity.ApplicationAction; +import com.threestar.trainus.domain.lesson.teacher.entity.ApplicationStatus; +import com.threestar.trainus.domain.lesson.teacher.entity.Category; +import com.threestar.trainus.domain.lesson.teacher.entity.Lesson; +import com.threestar.trainus.domain.lesson.teacher.entity.LessonApplication; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonApplicationRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonImageRepository; +import com.threestar.trainus.domain.lesson.teacher.repository.LessonRepository; +import com.threestar.trainus.domain.user.entity.User; +import com.threestar.trainus.domain.user.entity.UserRole; +import com.threestar.trainus.domain.user.service.UserService; +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +public class AdminLessonServiceTest { + @Mock + private LessonRepository lessonRepository; + + @Mock + private LessonImageRepository lessonImageRepository; + + @Mock + private LessonApplicationRepository lessonApplicationRepository; + + @Mock + private UserService userService; + + @InjectMocks + private AdminLessonService adminLessonService; + + @Test + @DisplayName("정상적인 레슨 생성 테스트") + void createLesson_Success() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + LessonCreateRequestDto request = new LessonCreateRequestDto( + "테스트 레슨", + "레슨 설명", + Category.GYM, + 30000, + 10, + now.plusDays(1), + now.plusDays(1).plusHours(2), + null, + false, + "서울시", + "강남구", + "역삼동", + "테스트 주소", + List.of() + ); + + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + Lesson savedLesson = Lesson.builder() + .lessonLeader(userId) + .lessonName("테스트 레슨") + .description("레슨 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(now.plusDays(1)) + .endAt(now.plusDays(1).plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(lessonRepository.existsDuplicateLesson(anyLong(), anyString(), any(LocalDateTime.class))) + .willReturn(false); + given(lessonRepository.hasTimeConflictLesson(anyLong(), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + given(lessonRepository.save(any(Lesson.class))).willReturn(savedLesson); + + LessonResponseDto response = adminLessonService.createLesson(request, userId); + + assertThat(response).isNotNull(); + assertThat(response.lessonName()).isEqualTo("테스트 레슨"); + assertThat(response.lessonLeader()).isEqualTo(userId); + verify(lessonRepository).save(any(Lesson.class)); + } + + @Test + @DisplayName("시작 시간이 과거인 경우 예외 발생") + void createLesson_InvalidStartTime() { + Long userId = 1L; + LocalDateTime pastTime = LocalDateTime.now().minusHours(1); + + LessonCreateRequestDto request = new LessonCreateRequestDto( + "테스트 레슨", + "레슨 설명", + Category.GYM, + 30000, + 10, + pastTime, // 과거 시간 + pastTime.plusHours(2), + null, + false, + "서울시", + "강남구", + "역삼동", + "테스트 주소", + List.of() + ); + + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + + assertThatThrownBy(() -> adminLessonService.createLesson(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.LESSON_START_TIME_INVALID); + } + + @Test + @DisplayName("중복 레슨 생성시 예외 발생") + void createLesson_DuplicateLesson() { + Long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + LessonCreateRequestDto request = new LessonCreateRequestDto( + "테스트 레슨", + "레슨 설명", + Category.GYM, + 30000, + 10, + now.plusDays(1), + now.plusDays(1).plusHours(2), + null, + false, + "서울시", + "강남구", + "역삼동", + "테스트 주소", + List.of() + ); + + User user = User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(lessonRepository.existsDuplicateLesson(anyLong(), anyString(), any(LocalDateTime.class))) + .willReturn(true); // 중복 레슨 존재 + + assertThatThrownBy(() -> adminLessonService.createLesson(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DUPLICATE_LESSON); + } + + @Test + @DisplayName("레슨 삭제 성공") + void deleteLesson_Success() { + Long lessonId = 1L; + Long userId = 1L; + LocalDateTime futureTime = LocalDateTime.now().plusDays(1); + + Lesson lesson = Lesson.builder() + .lessonLeader(userId) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(futureTime) + .endAt(futureTime.plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(userService.getUserById(userId)).willReturn(User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build()); + given(lessonRepository.findById(lessonId)).willReturn(Optional.of(lesson)); + given(lessonApplicationRepository.countByLessonAndStatus(eq(lesson), eq(ApplicationStatus.APPROVED))) + .willReturn(0); + given(lessonRepository.save(any(Lesson.class))).willReturn(lesson); + + adminLessonService.deleteLesson(lessonId, userId); + + verify(lessonRepository).save(lesson); + assertThat(lesson.isDeleted()).isTrue(); + } + + @Test + @DisplayName("다른 사용자의 레슨 삭제시 예외 발생") + void deleteLesson_AccessForbidden() { + Long lessonId = 1L; + Long userId = 1L; + Long otherUserId = 2L; + LocalDateTime futureTime = LocalDateTime.now().plusDays(1); + + Lesson lesson = Lesson.builder() + .lessonLeader(otherUserId) // 다른 사용자의 레슨 + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(futureTime) + .endAt(futureTime.plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + given(userService.getUserById(userId)).willReturn(User.builder() + .id(userId) + .email("test@test.com") + .nickname("테스트유저") + .role(UserRole.USER) + .build()); + given(lessonRepository.findById(lessonId)).willReturn(Optional.of(lesson)); + + assertThatThrownBy(() -> adminLessonService.deleteLesson(lessonId, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ACCESS_FORBIDDEN); + } + + @Test + @DisplayName("레슨 신청 승인 성공") + void processLessonApplication_Approve_Success() { + Long applicationId = 1L; + Long userId = 1L; + + User user = User.builder().id(2L).email("user@test.com").nickname("유저").role(UserRole.USER).build(); + Lesson lesson = Lesson.builder() + .lessonLeader(userId) + .lessonName("테스트 레슨") + .description("테스트 설명") + .category(Category.GYM) + .price(30000) + .maxParticipants(10) + .startAt(LocalDateTime.now().plusDays(1)) + .endAt(LocalDateTime.now().plusDays(1).plusHours(2)) + .openRun(false) + .city("서울시") + .district("강남구") + .dong("역삼동") + .addressDetail("테스트 주소") + .build(); + + LessonApplication application = LessonApplication.builder() + .user(user) + .lesson(lesson) + .build(); + + given(lessonApplicationRepository.findById(applicationId)).willReturn(Optional.of(application)); + given(lessonApplicationRepository.save(any(LessonApplication.class))).willReturn(application); + + var response = adminLessonService.processLessonApplication(applicationId, ApplicationAction.APPROVED, userId); + + assertThat(response).isNotNull(); + assertThat(response.status()).isEqualTo(ApplicationStatus.APPROVED); + verify(lessonApplicationRepository).save(application); + } + + @Test + @DisplayName("존재하지 않는 레슨 신청 처리시 예외 발생") + void processLessonApplication_NotFound() { + Long applicationId = 999L; + Long userId = 1L; + + given(lessonApplicationRepository.findById(applicationId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + adminLessonService.processLessonApplication(applicationId, ApplicationAction.APPROVED, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.LESSON_APPLICATION_NOT_FOUND); + } +} diff --git a/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitServiceTest.java b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitServiceTest.java new file mode 100644 index 00000000..6f99448c --- /dev/null +++ b/src/test/java/com/threestar/trainus/domain/lesson/teacher/service/LessonCreationLimitServiceTest.java @@ -0,0 +1,64 @@ +package com.threestar.trainus.domain.lesson.teacher.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import com.threestar.trainus.global.exception.domain.ErrorCode; +import com.threestar.trainus.global.exception.handler.BusinessException; + +@ExtendWith(MockitoExtension.class) +class LessonCreationLimitServiceTest { + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private LessonCreationLimitService lessonCreationLimitService; + + @Test + @DisplayName("첫 레슨 생성 시 제한 없이 성공한다") + void checkAndSetCreationLimit_FirstTime_Success() { + Long userId = 1L; + String expectedKey = "lesson_creation_limit:" + userId; + + when(redisTemplate.hasKey(expectedKey)).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + assertThatCode(() -> lessonCreationLimitService.checkAndSetCreationLimit(userId)) + .doesNotThrowAnyException(); + + verify(redisTemplate).hasKey(expectedKey); + verify(valueOperations).set(expectedKey, "restricted", Duration.ofMinutes(1)); + } + + @Test + @DisplayName("쿨타임이 남아있을 때 레슨 생성을 제한한다") + void checkAndSetCreationLimit_WithinCooltime_ThrowsException() { + Long userId = 1L; + String expectedKey = "lesson_creation_limit:" + userId; + + when(redisTemplate.hasKey(expectedKey)).thenReturn(true); + when(redisTemplate.getExpire(expectedKey)).thenReturn(30L); // 30초 남음 + + assertThatThrownBy(() -> lessonCreationLimitService.checkAndSetCreationLimit(userId)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException)e).getErrorCode()) + .isEqualTo(ErrorCode.LESSON_CREATION_TOO_FREQUENT); + + verify(redisTemplate).hasKey(expectedKey); + verify(redisTemplate, never()).opsForValue(); + } +}