diff --git a/build.gradle.kts b/build.gradle.kts index 4e9eb714..385342b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -108,6 +108,8 @@ dependencies { // Test Dependencies testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") + testRuntimeOnly("com.h2database:h2") // Documentation implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1") diff --git a/src/main/java/team/washer/server/v2/domain/reservation/entity/Reservation.java b/src/main/java/team/washer/server/v2/domain/reservation/entity/Reservation.java index 8c74481d..2a524f56 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/entity/Reservation.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/entity/Reservation.java @@ -126,18 +126,18 @@ public void clearCompletionCount() { } /** - * 예약 타임아웃 초과 여부를 반환합니다. + * 예약 타임아웃 초과 여부를 반환합니다. 타임아웃 유무와 길이는 {@link ReservationStatus}의 설정을 그대로 따르므로, + * 상태별 타임아웃을 바꾸려면 열거형만 수정하면 됩니다. * * @return 타임아웃 초과 여부 */ public boolean isExpired() { - LocalDateTime now = DateTimeUtil.nowInKorea(); + if (!this.status.hasTimeout()) { + return false; + } - return switch (this.status) { - case RESERVED -> - Duration.between(this.reservedAt, now).toMinutes() >= ReservationStatus.RESERVED.getTimeoutMinutes(); - default -> false; - }; + return Duration.between(this.reservedAt, DateTimeUtil.nowInKorea()).toMinutes() >= this.status + .getTimeoutMinutes(); } /** @@ -146,16 +146,14 @@ public boolean isExpired() { * @return 타임아웃까지 남은 시간 */ public Duration getRemainingTimeUntilTimeout() { - LocalDateTime now = DateTimeUtil.nowInKorea(); - - return switch (this.status) { - case RESERVED -> { - long minutes = ReservationStatus.RESERVED.getTimeoutMinutes() - - Duration.between(this.reservedAt, now).toMinutes(); - yield Duration.ofMinutes(Math.max(0, minutes)); - } - default -> Duration.ZERO; - }; + if (!this.status.hasTimeout()) { + return Duration.ZERO; + } + + final long remainingMinutes = this.status.getTimeoutMinutes() + - Duration.between(this.reservedAt, DateTimeUtil.nowInKorea()).toMinutes(); + + return Duration.ofMinutes(Math.max(0, remainingMinutes)); } /** @@ -247,6 +245,19 @@ public boolean isActive() { return this.status == ReservationStatus.RESERVED || this.status == ReservationStatus.RUNNING; } + /** + * 만료되지 않은 현재 활성 예약인지 반환합니다. + * + *

+ * 활성 상태이더라도 타임아웃이 지난 RESERVED 예약은 활성으로 세지 않습니다. 스케줄러가 아직 정리하지 못한 만료 예약이 새 예약을 + * 막거나 조회 결과에 노출되는 것을 방지하기 위함입니다. + * + * @return 만료되지 않은 활성 예약 여부 + */ + public boolean isCurrentlyActive() { + return isActive() && !isExpired(); + } + /** * 관리자 대리 생성 예약 여부를 반환합니다. * diff --git a/src/main/java/team/washer/server/v2/domain/reservation/repository/ReservationRepository.java b/src/main/java/team/washer/server/v2/domain/reservation/repository/ReservationRepository.java index 45522868..b7752023 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/repository/ReservationRepository.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/repository/ReservationRepository.java @@ -35,19 +35,11 @@ public interface ReservationRepository extends JpaRepository, List findByStatusIn(List statuses); - @Query("SELECT r FROM Reservation r WHERE r.user = :user AND r.status IN :statuses") - List findByUserAndStatusIn(@Param("user") User user, - @Param("statuses") List statuses); - @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT r FROM Reservation r JOIN FETCH r.machine WHERE r.user = :user AND r.status IN :statuses") List findByUserAndStatusInForUpdate(@Param("user") User user, @Param("statuses") List statuses); - @Query("SELECT r FROM Reservation r WHERE r.machine = :machine AND r.status IN :statuses") - List findByMachineAndStatusIn(@Param("machine") Machine machine, - @Param("statuses") List statuses); - default List findAllActiveReservations() { return findByStatusIn(List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)); } diff --git a/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/ReservationRepositoryCustom.java b/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/ReservationRepositoryCustom.java index 1cbe1266..c9265c7c 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/ReservationRepositoryCustom.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/ReservationRepositoryCustom.java @@ -6,9 +6,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import team.washer.server.v2.domain.machine.entity.Machine; import team.washer.server.v2.domain.machine.enums.MachineType; import team.washer.server.v2.domain.reservation.entity.Reservation; import team.washer.server.v2.domain.reservation.enums.ReservationStatus; +import team.washer.server.v2.domain.user.entity.User; public interface ReservationRepositoryCustom { @@ -56,13 +58,31 @@ Page findAllWithFilters(String userName, Pageable pageable); /** - * 호실 번호 기준 활성 예약 목록 조회 + * 사용자의 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다. + * + * @param user + * 조회 대상 사용자 + * @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순) + */ + List findCurrentlyActiveByUser(User user); + + /** + * 기기의 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다. + * + * @param machine + * 조회 대상 기기 + * @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순) + */ + List findCurrentlyActiveByMachine(Machine machine); + + /** + * 호실의 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다. * * @param roomNumber * 호실 번호 - * @return 해당 호실의 활성(RESERVED/RUNNING) 예약 목록 (createdAt 내림차순) + * @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순) */ - List findActiveReservationsByRoomNumber(String roomNumber); + List findCurrentlyActiveByRoomNumber(String roomNumber); /** * 기기별 예약 히스토리 조회 diff --git a/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/impl/ReservationRepositoryCustomImpl.java b/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/impl/ReservationRepositoryCustomImpl.java index cc11826b..b44bb8b5 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/impl/ReservationRepositoryCustomImpl.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/repository/custom/impl/ReservationRepositoryCustomImpl.java @@ -18,12 +18,15 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import team.washer.server.v2.domain.machine.entity.Machine; import team.washer.server.v2.domain.machine.enums.MachineType; import team.washer.server.v2.domain.reservation.entity.QReservation; import team.washer.server.v2.domain.reservation.entity.Reservation; import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.custom.ReservationRepositoryCustom; import team.washer.server.v2.domain.user.entity.QUser; +import team.washer.server.v2.domain.user.entity.User; +import team.washer.server.v2.global.util.DateTimeUtil; @Repository @RequiredArgsConstructor @@ -34,6 +37,9 @@ public class ReservationRepositoryCustomImpl implements ReservationRepositoryCus // QUser 기본 별칭(user)은 reservation.user 조인에 이미 사용되므로 대리 예약 생성자용 별칭을 따로 둔다 private static final QUser createdByUser = new QUser("createdByUser"); + private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED, + ReservationStatus.RUNNING); + private final JPAQueryFactory jpaQueryFactory; @Override @@ -83,14 +89,65 @@ public boolean existsConflictingReservation(Long machineId, } @Override - public List findActiveReservationsByRoomNumber(String roomNumber) { + public List findCurrentlyActiveByUser(User targetUser) { return jpaQueryFactory.selectFrom(reservation).join(reservation.user, user).fetchJoin() .join(reservation.machine, machine).fetchJoin() - .where(reservation.user.roomNumber.eq(roomNumber), - reservation.status.in(ReservationStatus.RESERVED, ReservationStatus.RUNNING)) + .where(reservation.user.eq(targetUser), currentlyActive()).orderBy(reservation.createdAt.desc()) + .fetch(); + } + + @Override + public List findCurrentlyActiveByMachine(Machine targetMachine) { + return jpaQueryFactory.selectFrom(reservation).join(reservation.machine, machine).fetchJoin() + .where(reservation.machine.eq(targetMachine), currentlyActive()).orderBy(reservation.createdAt.desc()) + .fetch(); + } + + @Override + public List findCurrentlyActiveByRoomNumber(String roomNumber) { + return jpaQueryFactory.selectFrom(reservation).join(reservation.user, user).fetchJoin() + .join(reservation.machine, machine).fetchJoin() + .where(reservation.user.roomNumber.eq(roomNumber), currentlyActive()) .orderBy(reservation.createdAt.desc()).fetch(); } + /** + * 만료되지 않은 활성 예약 조건을 반환합니다. {@link Reservation#isCurrentlyActive()}와 동일한 규칙을 쿼리 + * 조건으로 표현한 것으로, 전체를 로드한 뒤 메모리에서 거르지 않도록 합니다. + * + *

+ * 타임아웃 유무와 길이는 엔티티와 마찬가지로 {@link ReservationStatus}의 설정에서 파생되므로, 상태별 타임아웃을 바꾸면 + * 양쪽 판정이 함께 따라옵니다. + * + * @return 만료되지 않은 활성 예약 조건 + */ + private BooleanExpression currentlyActive() { + final LocalDateTime now = DateTimeUtil.nowInKorea(); + + return ACTIVE_STATUSES.stream().map(status -> notExpired(status, now)).reduce(BooleanExpression::or) + .orElseThrow(); + } + + /** + * 특정 상태의 만료되지 않은 예약 조건을 반환합니다. 타임아웃이 없는 상태는 상태 일치만으로 통과시키고, 타임아웃이 있는 상태는 컷오프 + * 이후에 예약된 건만 남깁니다. + * + * @param status + * 판정 대상 예약 상태 + * @param now + * 컷오프 계산 기준 시각 + * @return 해당 상태의 만료되지 않은 예약 조건 + */ + private BooleanExpression notExpired(final ReservationStatus status, final LocalDateTime now) { + final BooleanExpression statusMatches = reservation.status.eq(status); + + if (!status.hasTimeout()) { + return statusMatches; + } + + return statusMatches.and(reservation.reservedAt.gt(now.minusMinutes(status.getTimeoutMinutes()))); + } + @Override public List findExpiredReservations(ReservationStatus status, LocalDateTime threshold, diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryActiveReservationServiceImpl.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryActiveReservationServiceImpl.java index a227fe65..abfa43e9 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryActiveReservationServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryActiveReservationServiceImpl.java @@ -1,8 +1,5 @@ package team.washer.server.v2.domain.reservation.service.impl; -import java.util.Comparator; -import java.util.List; - import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,7 +8,6 @@ import team.themoment.sdk.exception.ExpectedException; import team.washer.server.v2.domain.reservation.dto.response.ReservationResDto; import team.washer.server.v2.domain.reservation.entity.Reservation; -import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.ReservationRepository; import team.washer.server.v2.domain.reservation.service.QueryActiveReservationService; import team.washer.server.v2.domain.user.entity.User; @@ -33,11 +29,9 @@ public ReservationResDto execute() { final User user = userRepository.findById(userId) .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND)); - final List activeReservations = reservationRepository.findByUserAndStatusIn(user, - List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)); - - final Reservation latest = activeReservations.stream().filter(r -> !r.isExpired()) - .max(Comparator.comparing(Reservation::getCreatedAt)).orElse(null); + // createdAt 내림차순으로 조회되므로 첫 건이 가장 최근 활성 예약이다 + final Reservation latest = reservationRepository.findCurrentlyActiveByUser(user).stream().findFirst() + .orElse(null); if (latest == null) { return null; diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryRoomActiveReservationsServiceImpl.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryRoomActiveReservationsServiceImpl.java index ee3e3e5a..9d3d95d1 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryRoomActiveReservationsServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/QueryRoomActiveReservationsServiceImpl.java @@ -29,8 +29,7 @@ public RoomActiveReservationsResDto execute() { final User user = userRepository.findById(userId) .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND)); - final var reservations = reservationRepository.findActiveReservationsByRoomNumber(user.getRoomNumber()).stream() - .filter(r -> !r.isExpired()) + final var reservations = reservationRepository.findCurrentlyActiveByRoomNumber(user.getRoomNumber()).stream() .map(r -> new ReservationResDto(r.getId(), r.getUser().getId(), r.getUser().getName(), diff --git a/src/main/java/team/washer/server/v2/domain/reservation/support/ReservationCreationSupport.java b/src/main/java/team/washer/server/v2/domain/reservation/support/ReservationCreationSupport.java index 890b4480..7c366b1b 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/support/ReservationCreationSupport.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/support/ReservationCreationSupport.java @@ -36,9 +36,6 @@ @RequiredArgsConstructor public class ReservationCreationSupport { - private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED, - ReservationStatus.RUNNING); - private final ReservationRepository reservationRepository; private final MachineRepository machineRepository; private final WashingBanRepository washingBanRepository; @@ -91,61 +88,49 @@ public Machine lockMachine(final Long machineId) { * 락을 획득한 기기 */ public void validateMachineAndReservations(final User user, final Machine machine) { - final var activeMachineReservations = reservationRepository.findByMachineAndStatusIn(machine, ACTIVE_STATUSES); + final var machineReservations = reservationRepository.findCurrentlyActiveByMachine(machine); // 기기 가용성 검증 if (machine.getAvailability() != MachineAvailability.AVAILABLE - && !canReuseStaleReservedSlot(machine, activeMachineReservations)) { + && !canReuseStaleReservedSlot(machine, machineReservations)) { throw new ExpectedException(String.format("해당 기기를 사용할 수 없습니다. 기기: %s", machine.getName()), HttpStatus.BAD_REQUEST); } // 기기 단위 중복 예약 검증 (가용성 플래그 드리프트에 대한 방어 심화) - if (hasCurrentActiveReservation(activeMachineReservations)) { + if (!machineReservations.isEmpty()) { throw new ExpectedException(String.format("해당 기기에 이미 진행 중인 예약이 있습니다. 기기: %s", machine.getName()), HttpStatus.CONFLICT); } // 개인 중복 예약 검증 (1인 1예약) - final var userActiveReservations = reservationRepository.findByUserAndStatusIn(user, ACTIVE_STATUSES); - if (hasCurrentActiveReservation(userActiveReservations)) { + if (!reservationRepository.findCurrentlyActiveByUser(user).isEmpty()) { throw new ExpectedException("이미 활성 예약이 존재합니다. 1인 1예약만 가능합니다.", HttpStatus.BAD_REQUEST); } // 동일 호실의 동일 유형 기기 중복 예약 검증 final boolean hasDuplicateTypeReservation = reservationRepository - .findActiveReservationsByRoomNumber(user.getRoomNumber()).stream() - .filter(reservation -> reservation.getMachine().getType() == machine.getType()) - .anyMatch(reservation -> reservation.isActive() && !reservation.isExpired()); + .findCurrentlyActiveByRoomNumber(user.getRoomNumber()).stream() + .anyMatch(reservation -> reservation.getMachine().getType() == machine.getType()); if (hasDuplicateTypeReservation) { throw new ExpectedException(String.format("해당 호실에 이미 %s 예약이 존재합니다. 동일 유형의 기기는 동시에 두 개 이상 예약할 수 없습니다.", machine.getType().getDescription()), HttpStatus.BAD_REQUEST); } } - /** - * 타임아웃이 지나지 않은 진짜 활성 예약이 있는지 판정합니다. - * - * @param reservations - * 판정 대상 예약 목록 - * @return 만료되지 않은 활성 예약 존재 여부 - */ - private boolean hasCurrentActiveReservation(final List reservations) { - return reservations.stream().anyMatch(reservation -> reservation.isActive() && !reservation.isExpired()); - } - /** * 만료된 예약만 남아 RESERVED로 굳어버린 기기를 재사용할 수 있는지 판정합니다. * * @param machine * 락을 획득한 기기 - * @param activeReservations - * 해당 기기의 활성 상태 예약 목록 + * @param currentlyActiveReservations + * 해당 기기의 만료되지 않은 활성 예약 목록 * @return 재사용 가능 여부 */ - private boolean canReuseStaleReservedSlot(final Machine machine, final List activeReservations) { + private boolean canReuseStaleReservedSlot(final Machine machine, + final List currentlyActiveReservations) { return machine.getStatus() == MachineStatus.NORMAL && machine.getAvailability() == MachineAvailability.RESERVED - && !hasCurrentActiveReservation(activeReservations); + && currentlyActiveReservations.isEmpty(); } /** diff --git a/src/main/java/team/washer/server/v2/domain/reservation/util/ActiveReservationSelector.java b/src/main/java/team/washer/server/v2/domain/reservation/util/ActiveReservationSelector.java index 1e79ec12..c5bb3049 100644 --- a/src/main/java/team/washer/server/v2/domain/reservation/util/ActiveReservationSelector.java +++ b/src/main/java/team/washer/server/v2/domain/reservation/util/ActiveReservationSelector.java @@ -32,7 +32,8 @@ public static Optional selectPrimary(List activeReserv if (activeReservations == null || activeReservations.isEmpty()) { return Optional.empty(); } - return activeReservations.stream().filter(Reservation::isRunning).findFirst().or(() -> activeReservations - .stream().filter(reservation -> reservation.isReserved() && !reservation.isExpired()).findFirst()); + // RUNNING이 하나도 없을 때만 두 번째 분기에 도달하므로, 여기서 통과하는 것은 만료되지 않은 RESERVED뿐이다 + return activeReservations.stream().filter(Reservation::isRunning).findFirst() + .or(() -> activeReservations.stream().filter(Reservation::isCurrentlyActive).findFirst()); } } diff --git a/src/test/java/team/washer/server/v2/domain/reservation/entity/ReservationTest.java b/src/test/java/team/washer/server/v2/domain/reservation/entity/ReservationTest.java index e785a549..2b5c8caa 100644 --- a/src/test/java/team/washer/server/v2/domain/reservation/entity/ReservationTest.java +++ b/src/test/java/team/washer/server/v2/domain/reservation/entity/ReservationTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.global.common.constants.ReservationConstants; @DisplayName("Reservation 예상 완료 시각 상한 검증") @@ -168,4 +169,57 @@ void shouldIncrementAndClear() { assertThat(reservation.getCompletionCount()).isZero(); } } + + @Nested + @DisplayName("현재 활성 예약 판정") + class IsCurrentlyActiveTest { + + private Reservation buildReservation(ReservationStatus status, int reservedMinutesAgo) { + return Reservation.builder().status(status) + .reservedAt(LocalDateTime.now(KOREA_ZONE).minusMinutes(reservedMinutesAgo)).build(); + } + + @Test + @DisplayName("타임아웃 전 RESERVED 예약은 활성으로 판정한다") + void shouldReturnTrue_WhenReservedAndNotExpired() { + // Given + var reservation = buildReservation(ReservationStatus.RESERVED, 1); + + // When & Then + assertThat(reservation.isCurrentlyActive()).isTrue(); + } + + @Test + @DisplayName("타임아웃이 지난 RESERVED 예약은 활성으로 판정하지 않는다") + void shouldReturnFalse_WhenReservedAndExpired() { + // Given + var reservation = buildReservation(ReservationStatus.RESERVED, + ReservationStatus.RESERVED.getTimeoutMinutes() + 1); + + // When & Then + assertThat(reservation.isCurrentlyActive()).isFalse(); + } + + @Test + @DisplayName("RUNNING 예약은 예약 시각과 무관하게 활성으로 판정한다") + void shouldReturnTrue_WhenRunning() { + // Given + var reservation = buildReservation(ReservationStatus.RUNNING, 120); + + // When & Then + assertThat(reservation.isCurrentlyActive()).isTrue(); + } + + @Test + @DisplayName("완료·취소된 예약은 활성으로 판정하지 않는다") + void shouldReturnFalse_WhenCompletedOrCancelled() { + // Given + var completed = buildReservation(ReservationStatus.COMPLETED, 1); + var cancelled = buildReservation(ReservationStatus.CANCELLED, 1); + + // When & Then + assertThat(completed.isCurrentlyActive()).isFalse(); + assertThat(cancelled.isCurrentlyActive()).isFalse(); + } + } } diff --git a/src/test/java/team/washer/server/v2/domain/reservation/repository/ReservationRepositoryCurrentlyActiveTest.java b/src/test/java/team/washer/server/v2/domain/reservation/repository/ReservationRepositoryCurrentlyActiveTest.java new file mode 100644 index 00000000..d649ed91 --- /dev/null +++ b/src/test/java/team/washer/server/v2/domain/reservation/repository/ReservationRepositoryCurrentlyActiveTest.java @@ -0,0 +1,158 @@ +package team.washer.server.v2.domain.reservation.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import team.washer.server.v2.domain.machine.entity.Machine; +import team.washer.server.v2.domain.machine.enums.MachineType; +import team.washer.server.v2.domain.machine.enums.Position; +import team.washer.server.v2.domain.reservation.entity.Reservation; +import team.washer.server.v2.domain.reservation.enums.ReservationStatus; +import team.washer.server.v2.domain.user.entity.User; +import team.washer.server.v2.global.config.JpaAuditingConfig; +import team.washer.server.v2.global.config.QueryDslConfig; +import team.washer.server.v2.global.util.DateTimeUtil; + +/** + * 만료 판정 규칙이 엔티티({@link Reservation#isCurrentlyActive()})와 QueryDSL 조건 양쪽에 각각 + * 구현되어 있으므로, 실제 DB에 대해 두 판정이 항상 같은 답을 내는지 검증합니다. + * + *

+ * 모킹 없이 실제 쿼리를 실행하는 유일한 지점이므로, 한쪽 규칙만 바뀌면 여기서 실패해야 합니다. + */ +@DataJpaTest +@Import({QueryDslConfig.class, JpaAuditingConfig.class}) +@TestPropertySource(properties = {"spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", "spring.docker.compose.enabled=false"}) +@DisplayName("ReservationRepository 활성 예약 조회 쿼리 테스트") +class ReservationRepositoryCurrentlyActiveTest { + + private static final String ROOM_NUMBER = "301"; + private static final int TIMEOUT_MINUTES = ReservationStatus.RESERVED.getTimeoutMinutes(); + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private TestEntityManager entityManager; + + private User owner; + private Machine washer; + + private Reservation freshReserved; + private Reservation justBeforeTimeoutReserved; + private Reservation exactlyAtTimeoutReserved; + private Reservation longExpiredReserved; + private Reservation futureReserved; + private Reservation longRunning; + private Reservation completed; + private Reservation cancelled; + + @BeforeEach + void setUp() { + final LocalDateTime now = DateTimeUtil.nowInKorea(); + + owner = persist(User.builder().name("김세탁").studentId("2101").roomNumber(ROOM_NUMBER).grade(1).floor(3).build()); + washer = persist(Machine.builder().name("W3L1").type(MachineType.WASHER).deviceId("device-w3l1").floor(3) + .position(Position.LEFT).number(1).build()); + + // 경계 직전: 타임아웃 30초 전이므로 활성 + freshReserved = persistReservation(ReservationStatus.RESERVED, now.minusMinutes(1)); + justBeforeTimeoutReserved = persistReservation(ReservationStatus.RESERVED, + now.minusMinutes(TIMEOUT_MINUTES).plusSeconds(30)); + // 경계 동률: 경과 시간이 정확히 타임아웃과 같으므로 만료 + exactlyAtTimeoutReserved = persistReservation(ReservationStatus.RESERVED, now.minusMinutes(TIMEOUT_MINUTES)); + longExpiredReserved = persistReservation(ReservationStatus.RESERVED, now.minusMinutes(TIMEOUT_MINUTES + 30)); + // 미래 예약 시각도 만료로 취급되지 않아야 한다 + futureReserved = persistReservation(ReservationStatus.RESERVED, now.plusMinutes(1)); + // RUNNING은 타임아웃 대상이 아니므로 아무리 오래되어도 활성 + longRunning = persistReservation(ReservationStatus.RUNNING, now.minusHours(3)); + completed = persistReservation(ReservationStatus.COMPLETED, now.minusMinutes(1)); + cancelled = persistReservation(ReservationStatus.CANCELLED, now.minusMinutes(1)); + + entityManager.flush(); + entityManager.clear(); + } + + @Nested + @DisplayName("findCurrentlyActiveByUser 메서드는") + class FindCurrentlyActiveByUser { + + @Test + @DisplayName("만료되지 않은 활성 예약만 반환한다") + void 만료되지_않은_활성_예약만_반환한다() { + assertActiveIdsAre(() -> reservationRepository.findCurrentlyActiveByUser(owner)); + } + } + + @Nested + @DisplayName("findCurrentlyActiveByMachine 메서드는") + class FindCurrentlyActiveByMachine { + + @Test + @DisplayName("만료되지 않은 활성 예약만 반환한다") + void 만료되지_않은_활성_예약만_반환한다() { + assertActiveIdsAre(() -> reservationRepository.findCurrentlyActiveByMachine(washer)); + } + } + + @Nested + @DisplayName("findCurrentlyActiveByRoomNumber 메서드는") + class FindCurrentlyActiveByRoomNumber { + + @Test + @DisplayName("만료되지 않은 활성 예약만 반환한다") + void 만료되지_않은_활성_예약만_반환한다() { + assertActiveIdsAre(() -> reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)); + } + } + + @Nested + @DisplayName("쿼리 조건과 엔티티 판정은") + class QueryAndEntityRule { + + @Test + @DisplayName("동일한 예약 집합에 대해 같은 결론을 낸다") + void 동일한_예약_집합에_대해_같은_결론을_낸다() { + final List byEntityRule = reservationRepository.findAll().stream() + .filter(Reservation::isCurrentlyActive).map(Reservation::getId).sorted().toList(); + final List byQueryRule = reservationRepository.findCurrentlyActiveByUser(owner).stream() + .map(Reservation::getId).sorted().toList(); + + assertThat(byQueryRule).isEqualTo(byEntityRule); + } + } + + private void assertActiveIdsAre(final Supplier> query) { + assertThat(query.get()).extracting(Reservation::getId).containsExactlyInAnyOrder(freshReserved.getId(), + justBeforeTimeoutReserved.getId(), + futureReserved.getId(), + longRunning.getId()); + + assertThat(query.get()).extracting(Reservation::getId).doesNotContain(exactlyAtTimeoutReserved.getId(), + longExpiredReserved.getId(), + completed.getId(), + cancelled.getId()); + } + + private Reservation persistReservation(final ReservationStatus status, final LocalDateTime reservedAt) { + return persist(Reservation.builder().user(owner).machine(washer).status(status).reservedAt(reservedAt).build()); + } + + private T persist(final T entity) { + return entityManager.persist(entity); + } +} diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/AdminCreateReservationServiceTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/AdminCreateReservationServiceTest.java index 93e3f088..44817e1d 100644 --- a/src/test/java/team/washer/server/v2/domain/reservation/service/AdminCreateReservationServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/reservation/service/AdminCreateReservationServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import java.util.List; @@ -217,9 +216,7 @@ void execute_ShouldThrowConflict_WhenMachineHasActiveReservation() { when(targetUser.getRoomNumber()).thenReturn(ROOM_NUMBER); when(machineRepository.findByIdForUpdate(MACHINE_ID)).thenReturn(Optional.of(machine)); when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); - when(activeReservation.isActive()).thenReturn(true); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())) - .thenReturn(List.of(activeReservation)); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of(activeReservation)); // When & Then assertThatThrownBy(() -> adminCreateReservationService.execute(reqDto)) @@ -238,9 +235,7 @@ void execute_ShouldThrowBadRequest_WhenTargetUserHasActiveReservation() { when(targetUser.getRoomNumber()).thenReturn(ROOM_NUMBER); when(machineRepository.findByIdForUpdate(MACHINE_ID)).thenReturn(Optional.of(machine)); when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); - when(activeReservation.isActive()).thenReturn(true); - when(reservationRepository.findByUserAndStatusIn(eq(targetUser), any())) - .thenReturn(List.of(activeReservation)); + when(reservationRepository.findCurrentlyActiveByUser(targetUser)).thenReturn(List.of(activeReservation)); // When & Then assertThatThrownBy(() -> adminCreateReservationService.execute(reqDto)) @@ -260,8 +255,7 @@ void execute_ShouldThrowBadRequest_WhenRoomHasSameTypeReservation() { when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); when(machine.getType()).thenReturn(MachineType.WASHER); when(activeReservation.getMachine()).thenReturn(machine); - when(activeReservation.isActive()).thenReturn(true); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)) + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)) .thenReturn(List.of(activeReservation)); // When & Then diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/CreateReservationServiceTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/CreateReservationServiceTest.java index adfb8db5..31c4730e 100644 --- a/src/test/java/team/washer/server/v2/domain/reservation/service/CreateReservationServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/reservation/service/CreateReservationServiceTest.java @@ -29,7 +29,6 @@ import team.washer.server.v2.domain.reservation.dto.request.CreateReservationReqDto; import team.washer.server.v2.domain.reservation.dto.response.ReservationResDto; import team.washer.server.v2.domain.reservation.entity.Reservation; -import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.ReservationRepository; import team.washer.server.v2.domain.reservation.service.impl.CreateReservationServiceImpl; import team.washer.server.v2.domain.reservation.support.ReservationCreationSupport; @@ -37,7 +36,6 @@ import team.washer.server.v2.domain.user.entity.User; import team.washer.server.v2.domain.user.repository.UserRepository; import team.washer.server.v2.global.security.provider.CurrentUserProvider; -import team.washer.server.v2.global.util.DateTimeUtil; @ExtendWith(MockitoExtension.class) class CreateReservationServiceTest { @@ -101,9 +99,9 @@ void execute_ShouldCreateReservation_WhenValidRequest() { when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); when(machine.getType()).thenReturn(MachineType.WASHER); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())).thenReturn(List.of()); - when(reservationRepository.findByUserAndStatusIn(eq(user), any())).thenReturn(List.of()); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)).thenReturn(List.of()); when(reservationRepository.save(any(Reservation.class))).thenReturn(reservation); when(reservation.getId()).thenReturn(1L); @@ -128,8 +126,6 @@ void execute_ShouldCreateReservation_WhenOnlyExpiredMachineReservationExists() { final var reqDto = new CreateReservationReqDto(1L); var machineWithExpiredReservation = Machine.builder().name("세탁기-1").type(MachineType.WASHER) .status(MachineStatus.NORMAL).availability(MachineAvailability.RESERVED).build(); - var expiredReservation = Reservation.builder().user(user).machine(machineWithExpiredReservation) - .reservedAt(DateTimeUtil.nowInKorea().minusMinutes(6)).status(ReservationStatus.RESERVED).build(); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); when(machineRepository.findByIdForUpdate(reqDto.machineId())) @@ -138,10 +134,11 @@ void execute_ShouldCreateReservation_WhenOnlyExpiredMachineReservationExists() { when(penaltyRedisUtil.isBlocked(ROOM_NUMBER)).thenReturn(false); when(reservationEnvironment.disableTimeRestriction()).thenReturn(true); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); - when(reservationRepository.findByMachineAndStatusIn(eq(machineWithExpiredReservation), any())) - .thenReturn(List.of(expiredReservation)); - when(reservationRepository.findByUserAndStatusIn(eq(user), any())).thenReturn(List.of()); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)).thenReturn(List.of()); + // 만료된 RESERVED 예약은 쿼리 단계에서 제외되므로 활성 예약이 없는 것으로 조회된다 + when(reservationRepository.findCurrentlyActiveByMachine(machineWithExpiredReservation)) + .thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)).thenReturn(List.of()); when(reservationRepository.save(any(Reservation.class))).thenReturn(reservation); when(reservation.getId()).thenReturn(1L); when(reservation.getUser()).thenReturn(user); @@ -153,7 +150,6 @@ void execute_ShouldCreateReservation_WhenOnlyExpiredMachineReservationExists() { // Then assertThat(result).isNotNull(); - assertThat(expiredReservation.getStatus()).isEqualTo(ReservationStatus.RESERVED); verify(reservationRepository, never()).saveAll(anyList()); verify(machineRepository, times(1)).save(machineWithExpiredReservation); verify(reservationRepository).save(any(Reservation.class)); @@ -167,8 +163,6 @@ void execute_ShouldThrowException_WhenUnavailableMachineOnlyHasExpiredReservatio final var reqDto = new CreateReservationReqDto(1L); var unavailableMachine = Machine.builder().name("세탁기 1").type(MachineType.WASHER) .status(MachineStatus.MALFUNCTION).availability(MachineAvailability.UNAVAILABLE).build(); - var expiredReservation = Reservation.builder().user(user).machine(unavailableMachine) - .reservedAt(DateTimeUtil.nowInKorea().minusMinutes(6)).status(ReservationStatus.RESERVED).build(); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); when(machineRepository.findByIdForUpdate(reqDto.machineId())).thenReturn(Optional.of(unavailableMachine)); @@ -176,13 +170,12 @@ void execute_ShouldThrowException_WhenUnavailableMachineOnlyHasExpiredReservatio when(penaltyRedisUtil.isBlocked(ROOM_NUMBER)).thenReturn(false); when(reservationEnvironment.disableTimeRestriction()).thenReturn(true); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); - when(reservationRepository.findByMachineAndStatusIn(eq(unavailableMachine), any())) - .thenReturn(List.of(expiredReservation)); + // 만료된 RESERVED 예약은 쿼리 단계에서 제외되므로 활성 예약이 없는 것으로 조회된다 + when(reservationRepository.findCurrentlyActiveByMachine(unavailableMachine)).thenReturn(List.of()); // When & Then assertThatThrownBy(() -> createReservationService.execute(reqDto)).isInstanceOf(ExpectedException.class) .hasMessageContaining("해당 기기를 사용할 수 없습니다"); - assertThat(expiredReservation.getStatus()).isEqualTo(ReservationStatus.RESERVED); verify(reservationRepository, never()).saveAll(anyList()); verify(machineRepository, never()).save(unavailableMachine); } @@ -202,9 +195,9 @@ void execute_ShouldCreateReservation_WhenRoomHasDifferentTypeActiveReservation() when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); when(machine.getType()).thenReturn(MachineType.DRYER); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())).thenReturn(List.of()); - when(reservationRepository.findByUserAndStatusIn(eq(user), any())).thenReturn(List.of()); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)).thenReturn(List.of()); when(reservationRepository.save(any(Reservation.class))).thenReturn(reservation); when(reservation.getId()).thenReturn(2L); @@ -271,7 +264,7 @@ void execute_ShouldThrowException_WhenMachineNotAvailable() { when(reservationEnvironment.disableTimeRestriction()).thenReturn(true); when(machine.getAvailability()).thenReturn(MachineAvailability.IN_USE); when(machine.getName()).thenReturn("세탁기-1"); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of()); // When & Then assertThatThrownBy(() -> createReservationService.execute(reqDto)).isInstanceOf(ExpectedException.class) @@ -293,9 +286,7 @@ void execute_ShouldThrowConflict_WhenMachineAlreadyHasActiveReservation() { when(reservationEnvironment.disableTimeRestriction()).thenReturn(true); when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); when(machine.getName()).thenReturn("세탁기-1"); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())).thenReturn(List.of(reservation)); - when(reservation.isActive()).thenReturn(true); - when(reservation.isExpired()).thenReturn(false); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of(reservation)); // When & Then assertThatThrownBy(() -> createReservationService.execute(reqDto)).isInstanceOf(ExpectedException.class) @@ -317,10 +308,8 @@ void execute_ShouldThrowException_WhenUserAlreadyHasActiveReservation() { when(penaltyRedisUtil.isBlocked(ROOM_NUMBER)).thenReturn(false); when(reservationEnvironment.disableTimeRestriction()).thenReturn(true); when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())).thenReturn(List.of()); - when(reservationRepository.findByUserAndStatusIn(eq(user), any())).thenReturn(List.of(reservation)); - when(reservation.isActive()).thenReturn(true); - when(reservation.isExpired()).thenReturn(false); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(List.of(reservation)); // When & Then assertThatThrownBy(() -> createReservationService.execute(reqDto)).isInstanceOf(ExpectedException.class) @@ -379,13 +368,10 @@ void execute_ShouldThrowException_WhenRoomAlreadyHasSameTypeActiveReservation() when(machine.getAvailability()).thenReturn(MachineAvailability.AVAILABLE); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); when(machine.getType()).thenReturn(MachineType.WASHER); - when(reservationRepository.findByMachineAndStatusIn(eq(machine), any())).thenReturn(List.of()); - when(reservationRepository.findByUserAndStatusIn(eq(user), any())).thenReturn(List.of()); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)) - .thenReturn(List.of(reservation)); + when(reservationRepository.findCurrentlyActiveByMachine(machine)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(List.of()); + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)).thenReturn(List.of(reservation)); when(reservation.getMachine()).thenReturn(machine); - when(reservation.isActive()).thenReturn(true); - when(reservation.isExpired()).thenReturn(false); // When & Then assertThatThrownBy(() -> createReservationService.execute(reqDto)).isInstanceOf(ExpectedException.class) diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/QueryActiveReservationServiceTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/QueryActiveReservationServiceTest.java index fc561fe4..53934608 100644 --- a/src/test/java/team/washer/server/v2/domain/reservation/service/QueryActiveReservationServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/reservation/service/QueryActiveReservationServiceTest.java @@ -2,8 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,9 +63,7 @@ void execute_ShouldReturnReservation_WhenValidActiveReservationExists() { when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); final Reservation reservation = mock(Reservation.class); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(reservationRepository.findByUserAndStatusIn(eq(user), anyList())).thenReturn(List.of(reservation)); - when(reservation.isExpired()).thenReturn(false); - when(reservation.getCreatedAt()).thenReturn(LocalDateTime.now()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(List.of(reservation)); stubReservationDtoFields(reservation, 1L); // When @@ -79,44 +75,16 @@ void execute_ShouldReturnReservation_WhenValidActiveReservationExists() { } @Test - @DisplayName("만료 예약과 유효 예약이 혼재하면 유효한 예약을 반환한다") - void execute_ShouldReturnValidReservation_WhenMixedExpiredAndValidReservationsExist() { + @DisplayName("활성 예약이 여러 개이면 조회 순서상 가장 최근인 첫 예약을 반환한다") + void execute_ShouldReturnLatestReservation_WhenMultipleActiveReservationsExist() { // Given when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); - final Reservation expiredReservation = mock(Reservation.class); - final Reservation validReservation = mock(Reservation.class); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(reservationRepository.findByUserAndStatusIn(eq(user), anyList())) - .thenReturn(List.of(expiredReservation, validReservation)); - when(expiredReservation.isExpired()).thenReturn(true); - when(validReservation.isExpired()).thenReturn(false); - when(validReservation.getCreatedAt()).thenReturn(LocalDateTime.now()); - stubReservationDtoFields(validReservation, 2L); - - // When - final ReservationResDto result = queryActiveReservationService.execute(); - - // Then - assertThat(result).isNotNull(); - assertThat(result.id()).isEqualTo(2L); - } - - @Test - @DisplayName("유효한 예약이 여러 개이면 생성 시각이 가장 최근인 예약을 반환한다") - void execute_ShouldReturnLatestReservation_WhenMultipleValidReservationsExist() { - // Given - when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); - final Reservation olderReservation = mock(Reservation.class); final Reservation newerReservation = mock(Reservation.class); - final LocalDateTime older = LocalDateTime.now().minusMinutes(10); - final LocalDateTime newer = LocalDateTime.now(); + final Reservation olderReservation = mock(Reservation.class); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(reservationRepository.findByUserAndStatusIn(eq(user), anyList())) - .thenReturn(List.of(olderReservation, newerReservation)); - when(olderReservation.isExpired()).thenReturn(false); - when(newerReservation.isExpired()).thenReturn(false); - when(olderReservation.getCreatedAt()).thenReturn(older); - when(newerReservation.getCreatedAt()).thenReturn(newer); + // 리포지토리가 createdAt 내림차순으로 반환한다 + when(reservationRepository.findCurrentlyActiveByUser(user)) + .thenReturn(List.of(newerReservation, olderReservation)); stubReservationDtoFields(newerReservation, 2L); // When @@ -127,31 +95,13 @@ void execute_ShouldReturnLatestReservation_WhenMultipleValidReservationsExist() assertThat(result.id()).isEqualTo(2L); } - @Test - @DisplayName("만료된 예약만 존재하면 null을 반환한다") - void execute_ShouldReturnNull_WhenOnlyExpiredReservationsExist() { - // Given - when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); - final Reservation expiredReservation = mock(Reservation.class); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(reservationRepository.findByUserAndStatusIn(eq(user), anyList())) - .thenReturn(List.of(expiredReservation)); - when(expiredReservation.isExpired()).thenReturn(true); - - // When - final ReservationResDto result = queryActiveReservationService.execute(); - - // Then - assertThat(result).isNull(); - } - @Test @DisplayName("활성 예약이 없으면 null을 반환한다") void execute_ShouldReturnNull_WhenNoActiveReservationsExist() { // Given when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(reservationRepository.findByUserAndStatusIn(eq(user), anyList())).thenReturn(Collections.emptyList()); + when(reservationRepository.findCurrentlyActiveByUser(user)).thenReturn(Collections.emptyList()); // When final ReservationResDto result = queryActiveReservationService.execute(); diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/QueryRoomActiveReservationsServiceTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/QueryRoomActiveReservationsServiceTest.java index d099c8f2..e405bfda 100644 --- a/src/test/java/team/washer/server/v2/domain/reservation/service/QueryRoomActiveReservationsServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/reservation/service/QueryRoomActiveReservationsServiceTest.java @@ -66,9 +66,7 @@ void execute_ShouldReturnReservations_WhenValidActiveReservationsExist() { when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); final Reservation reservation = mock(Reservation.class); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)) - .thenReturn(List.of(reservation)); - when(reservation.isExpired()).thenReturn(false); + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)).thenReturn(List.of(reservation)); stubReservationDtoFields(reservation, 1L); // When @@ -88,10 +86,8 @@ void execute_ShouldReturnTwoReservations_WhenBothWasherAndDryerReservationsExist when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); final Reservation washerReservation = mock(Reservation.class); final Reservation dryerReservation = mock(Reservation.class); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)) + when(reservationRepository.findCurrentlyActiveByRoomNumber(ROOM_NUMBER)) .thenReturn(List.of(washerReservation, dryerReservation)); - when(washerReservation.isExpired()).thenReturn(false); - when(dryerReservation.isExpired()).thenReturn(false); stubReservationDtoFields(washerReservation, 1L); stubReservationDtoFields(dryerReservation, 2L); @@ -102,48 +98,6 @@ void execute_ShouldReturnTwoReservations_WhenBothWasherAndDryerReservationsExist assertThat(result.reservations()).hasSize(2); } - @Test - @DisplayName("만료 예약과 유효 예약이 혼재하면 유효한 예약만 반환한다") - void execute_ShouldReturnOnlyValidReservations_WhenMixedExpiredAndValidReservationsExist() { - // Given - when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); - final Reservation expiredReservation = mock(Reservation.class); - final Reservation validReservation = mock(Reservation.class); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)) - .thenReturn(List.of(expiredReservation, validReservation)); - when(expiredReservation.isExpired()).thenReturn(true); - when(validReservation.isExpired()).thenReturn(false); - stubReservationDtoFields(validReservation, 2L); - - // When - final RoomActiveReservationsResDto result = queryRoomActiveReservationsService.execute(); - - // Then - assertThat(result.reservations()).hasSize(1); - assertThat(result.reservations().get(0).id()).isEqualTo(2L); - } - - @Test - @DisplayName("만료된 예약만 존재하면 빈 목록을 반환한다") - void execute_ShouldReturnEmptyList_WhenOnlyExpiredReservationsExist() { - // Given - when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); - final Reservation expiredReservation = mock(Reservation.class); - when(reservationRepository.findActiveReservationsByRoomNumber(ROOM_NUMBER)) - .thenReturn(List.of(expiredReservation)); - when(expiredReservation.isExpired()).thenReturn(true); - - // When - final RoomActiveReservationsResDto result = queryRoomActiveReservationsService.execute(); - - // Then - assertThat(result.reservations()).isEmpty(); - } - @Test @DisplayName("호실에 활성 예약이 없으면 빈 목록을 반환한다") void execute_ShouldReturnEmptyList_WhenNoActiveReservationsExist() { @@ -151,7 +105,7 @@ void execute_ShouldReturnEmptyList_WhenNoActiveReservationsExist() { when(currentUserProvider.getCurrentUserId()).thenReturn(USER_ID); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); when(user.getRoomNumber()).thenReturn(ROOM_NUMBER); - when(reservationRepository.findActiveReservationsByRoomNumber(anyString())) + when(reservationRepository.findCurrentlyActiveByRoomNumber(anyString())) .thenReturn(Collections.emptyList()); // When