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