From 2b85763777e5460d4b7c2456bced474860529a6f Mon Sep 17 00:00:00 2001 From: d1ng1724 Date: Thu, 10 Sep 2026 11:29:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?add(reservation):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EC=9D=84=20=EC=A0=9C=EC=99=B8=ED=95=9C=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=98=88=EC=95=BD=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기기 ID·기기 목록·건수·사용자 존재 여부를 만료 판정까지 포함해 조회하는 쿼리 네 개를 추가하였습니다. 기존 currentlyActive() 조건을 그대로 재사용하므로 상태별 타임아웃이 바뀌어도 판정이 함께 따라옵니다. - findCurrentlyActiveByMachineId: 기기 엔티티 없이 ID만으로 조회 - findCurrentlyActiveMachineIds: 활성 예약이 걸린 기기 ID 목록 - countCurrentlyActive: 활성 예약 수 집계 - existsCurrentlyActiveByUser: 사용자의 활성 예약 존재 여부 Claude-Session: https://claude.ai/code/session_015e3wA7eCqrHBVCvcZ51yM2 --- .../custom/ReservationRepositoryCustom.java | 32 +++++++++++++++++++ .../impl/ReservationRepositoryCustomImpl.java | 27 ++++++++++++++++ 2 files changed, 59 insertions(+) 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 c9265c7c..b373897a 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 @@ -84,6 +84,38 @@ Page findAllWithFilters(String userName, */ List findCurrentlyActiveByRoomNumber(String roomNumber); + /** + * 기기 ID로 현재 활성 예약 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약은 쿼리 단계에서 제외됩니다. + * + * @param machineId + * 조회 대상 기기 ID + * @return 만료되지 않은 활성 예약 목록 (createdAt 내림차순) + */ + List findCurrentlyActiveByMachineId(Long machineId); + + /** + * 현재 활성 예약이 걸려 있는 기기 ID 목록을 조회합니다. 타임아웃이 지난 RESERVED 예약만 남은 기기는 포함되지 않습니다. + * + * @return 만료되지 않은 활성 예약이 있는 기기 ID 목록 + */ + List findCurrentlyActiveMachineIds(); + + /** + * 현재 활성 예약 수를 반환합니다. 타임아웃이 지난 RESERVED 예약은 집계에서 제외됩니다. + * + * @return 만료되지 않은 활성 예약 수 + */ + long countCurrentlyActive(); + + /** + * 사용자에게 현재 활성 예약이 있는지 반환합니다. 타임아웃이 지난 RESERVED 예약만 남아 있으면 거짓입니다. + * + * @param user + * 조회 대상 사용자 + * @return 만료되지 않은 활성 예약 존재 여부 + */ + boolean existsCurrentlyActiveByUser(User user); + /** * 기기별 예약 히스토리 조회 * 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 b44bb8b5..44abcb53 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 @@ -111,6 +111,33 @@ public List findCurrentlyActiveByRoomNumber(String roomNumber) { .orderBy(reservation.createdAt.desc()).fetch(); } + @Override + public List findCurrentlyActiveByMachineId(Long machineId) { + return jpaQueryFactory.selectFrom(reservation).join(reservation.machine, machine).fetchJoin() + .join(reservation.user, user).fetchJoin().where(reservation.machine.id.eq(machineId), currentlyActive()) + .orderBy(reservation.createdAt.desc()).fetch(); + } + + @Override + public List findCurrentlyActiveMachineIds() { + return jpaQueryFactory.select(reservation.machine.id).distinct().from(reservation).where(currentlyActive()) + .fetch(); + } + + @Override + public long countCurrentlyActive() { + final var total = jpaQueryFactory.select(reservation.count()).from(reservation).where(currentlyActive()) + .fetchOne(); + + return total != null ? total : 0L; + } + + @Override + public boolean existsCurrentlyActiveByUser(User targetUser) { + return jpaQueryFactory.selectOne().from(reservation).where(reservation.user.eq(targetUser), currentlyActive()) + .fetchFirst() != null; + } + /** * 만료되지 않은 활성 예약 조건을 반환합니다. {@link Reservation#isCurrentlyActive()}와 동일한 규칙을 쿼리 * 조건으로 표현한 것으로, 전체를 로드한 뒤 메모리에서 거르지 않도록 합니다. From 7d62dfba802320f5fb2e1c619538624fde976ca3 Mon Sep 17 00:00:00 2001 From: d1ng1724 Date: Thu, 10 Sep 2026 11:29:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(reservation):=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=EC=9D=84=20=ED=99=9C=EC=84=B1=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8C=90=EC=A0=95=ED=95=98=EB=8D=98=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status IN (RESERVED, RUNNING)만 보던 판정 지점을 만료 예약까지 거르는 쿼리로 전환하여, 같은 예약이 화면과 내부 로직에서 다르게 해석되던 문제를 해소하였습니다. - UpdateMachineStatusServiceImpl: 고장 복구 시 만료 예약을 근거로 markAsReserved()가 호출되어 기기가 다시 잠기던 문제를 해소하였습니다 - ShutdownIdleMachinesServiceImpl: 만료 예약만 걸린 기기를 유휴 전원 차단 대상에 포함하도록 하였습니다 - DeleteMachineServiceImpl: 만료 예약만 남은 기기를 삭제할 수 있도록 하였습니다 - QueryAdminDashboardServiceImpl: 활성 예약 집계에서 만료 예약을 제외하여 기기 목록에 보이는 상태와 어긋나지 않도록 하였습니다 - DeleteUserServiceImpl: 만료 예약만 남은 사용자를 관리자가 삭제할 수 있도록 하여 본인 탈퇴와의 비대칭을 없앴습니다 findActiveReservationByMachineId는 만료 제외가 이름에 드러나지 않아 findCurrentlyActiveReservationByMachineId로 바꾸고, 만료 판정을 ActiveReservationSelector의 부수 효과가 아니라 쿼리 단계에서 처리하도록 하였습니다. 사용처가 사라진 countActiveReservations, existsByUserAndStatusIn은 제거하였습니다. SmartThings가 보고한 실제 기기 상태를 함께 보는 ReservationLifecycleProcessor, ForceStopMachineServiceImpl과 통세척 계열은 대상에서 제외하였습니다. Closes #115 Claude-Session: https://claude.ai/code/session_015e3wA7eCqrHBVCvcZ51yM2 --- .../impl/QueryAdminDashboardServiceImpl.java | 2 +- .../impl/DeleteMachineServiceImpl.java | 2 +- .../QueryAllMachinesStatusServiceImpl.java | 3 +- .../impl/UpdateMachineStatusServiceImpl.java | 9 +- .../repository/ReservationRepository.java | 22 ++--- .../impl/ShutdownIdleMachinesServiceImpl.java | 8 +- .../service/impl/DeleteUserServiceImpl.java | 8 +- .../QueryAdminDashboardServiceTest.java | 4 +- .../service/DeleteMachineServiceTest.java | 5 +- .../QueryAllMachinesStatusServiceTest.java | 12 +-- .../UpdateMachineStatusServiceTest.java | 8 +- ...ervationRepositoryCurrentlyActiveTest.java | 85 +++++++++++++++++++ .../ShutdownIdleMachinesServiceTest.java | 14 ++- .../user/service/DeleteUserServiceTest.java | 57 ++++++++----- 14 files changed, 162 insertions(+), 77 deletions(-) diff --git a/src/main/java/team/washer/server/v2/domain/admin/service/impl/QueryAdminDashboardServiceImpl.java b/src/main/java/team/washer/server/v2/domain/admin/service/impl/QueryAdminDashboardServiceImpl.java index 4c88c0ea..3be5d79f 100644 --- a/src/main/java/team/washer/server/v2/domain/admin/service/impl/QueryAdminDashboardServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/admin/service/impl/QueryAdminDashboardServiceImpl.java @@ -32,7 +32,7 @@ public class QueryAdminDashboardServiceImpl implements QueryAdminDashboardServic public AdminDashboardResDto execute() { log.info("Querying admin dashboard statistics"); - var activeReservations = reservationRepository.countActiveReservations(); + var activeReservations = reservationRepository.countCurrentlyActive(); var pendingReports = malfunctionReportRepository.countByStatus(MalfunctionReportStatus.PENDING); var processingReports = malfunctionReportRepository.countByStatus(MalfunctionReportStatus.IN_PROGRESS); var completedReports = malfunctionReportRepository.countByStatus(MalfunctionReportStatus.RESOLVED); diff --git a/src/main/java/team/washer/server/v2/domain/machine/service/impl/DeleteMachineServiceImpl.java b/src/main/java/team/washer/server/v2/domain/machine/service/impl/DeleteMachineServiceImpl.java index 87857acf..8eb002ec 100644 --- a/src/main/java/team/washer/server/v2/domain/machine/service/impl/DeleteMachineServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/machine/service/impl/DeleteMachineServiceImpl.java @@ -24,7 +24,7 @@ public DeleteMachineResDto execute(Long machineId) { final var machine = machineRepository.findById(machineId) .orElseThrow(() -> new ExpectedException("기기를 찾을 수 없습니다", HttpStatus.NOT_FOUND)); - if (reservationRepository.findActiveReservationByMachineId(machineId).isPresent()) { + if (reservationRepository.findCurrentlyActiveReservationByMachineId(machineId).isPresent()) { throw new ExpectedException("활성 예약이 존재하는 기기는 삭제할 수 없습니다", HttpStatus.BAD_REQUEST); } diff --git a/src/main/java/team/washer/server/v2/domain/machine/service/impl/QueryAllMachinesStatusServiceImpl.java b/src/main/java/team/washer/server/v2/domain/machine/service/impl/QueryAllMachinesStatusServiceImpl.java index a389474d..2542c88d 100644 --- a/src/main/java/team/washer/server/v2/domain/machine/service/impl/QueryAllMachinesStatusServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/machine/service/impl/QueryAllMachinesStatusServiceImpl.java @@ -55,7 +55,8 @@ public List execute(Long userId, boolean sorted) { var deviceStatusMap = deviceStatusQuerySupport.queryAllDevicesStatus(deviceIds); var results = machines.stream().map(machine -> { - var reservation = reservationRepository.findActiveReservationByMachineId(machine.getId()).orElse(null); + var reservation = reservationRepository.findCurrentlyActiveReservationByMachineId(machine.getId()) + .orElse(null); return mapToStatusDto(machine, deviceStatusMap.get(machine.getDeviceId()), reservation); }).toList(); diff --git a/src/main/java/team/washer/server/v2/domain/machine/service/impl/UpdateMachineStatusServiceImpl.java b/src/main/java/team/washer/server/v2/domain/machine/service/impl/UpdateMachineStatusServiceImpl.java index 61e8f413..70a2c2b1 100644 --- a/src/main/java/team/washer/server/v2/domain/machine/service/impl/UpdateMachineStatusServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/machine/service/impl/UpdateMachineStatusServiceImpl.java @@ -34,8 +34,9 @@ public MachineStatusUpdateResDto execute(Long machineId, MachineStatus status) { } else if (status == MachineStatus.NORMAL) { machine.markAsNormal(); - // 복구 후 실제 예약 상태에 맞게 availability 재동기화 - findActiveReservation(machine).ifPresent(reservation -> { + // 복구 후 실제 예약 상태에 맞게 availability 재동기화. 만료된 RESERVED 예약은 활성으로 보지 않으므로 + // markAsNormal()이 설정한 AVAILABLE이 그대로 유지된다 + findCurrentlyActiveReservation(machine).ifPresent(reservation -> { switch (reservation.getStatus()) { case RESERVED -> machine.markAsReserved(); case RUNNING -> machine.markAsInUse(); @@ -53,7 +54,7 @@ public MachineStatusUpdateResDto execute(Long machineId, MachineStatus status) { savedMachine.getAvailability()); } - private Optional findActiveReservation(Machine machine) { - return reservationRepository.findActiveReservationByMachineId(machine.getId()); + private Optional findCurrentlyActiveReservation(Machine machine) { + return reservationRepository.findCurrentlyActiveReservationByMachineId(machine.getId()); } } 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 b7752023..4d65c8bc 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 @@ -44,23 +44,21 @@ default List findAllActiveReservations() { return findByStatusIn(List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)); } - @Query("SELECT COUNT(r) FROM Reservation r WHERE r.status IN :statuses") - long countAllActiveReservations(@Param("statuses") List statuses); - - default long countActiveReservations() { - return countAllActiveReservations(List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)); - } - @Query("SELECT r FROM Reservation r WHERE r.machine.id = :machineId AND r.status IN :statuses ORDER BY r.createdAt DESC") List findFirstActiveReservationByMachineId(@Param("machineId") Long machineId, @Param("statuses") List statuses); /** - * 기기의 대표 활성 예약을 조회한다. 선택 규칙은 {@link ActiveReservationSelector}가 정의한다. + * 기기의 대표 활성 예약을 조회한다. 만료 예약 제외는 + * {@link ReservationRepositoryCustom#findCurrentlyActiveByMachineId(Long)}가 쿼리 + * 단계에서 처리하고, 남은 후보 중 대표를 고르는 규칙은 {@link ActiveReservationSelector}가 정의한다. + * + *

+ * 타임아웃이 지난 RESERVED 예약만 남은 기기는 {@link Optional#empty()}가 된다. 스케줄러가 아직 정리하지 못한 + * 만료 예약이 기기를 점유한 것처럼 보이게 하지 않기 위함이다. */ - default Optional findActiveReservationByMachineId(Long machineId) { - return ActiveReservationSelector.selectPrimary(findFirstActiveReservationByMachineId(machineId, - List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING))); + default Optional findCurrentlyActiveReservationByMachineId(Long machineId) { + return ActiveReservationSelector.selectPrimary(findCurrentlyActiveByMachineId(machineId)); } @Query("SELECT COUNT(r) FROM Reservation r WHERE r.machine = :machine AND r.status IN :statuses") @@ -76,6 +74,4 @@ default List findAllRunningReservations() { @Query("SELECT DISTINCT r.machine.id FROM Reservation r WHERE r.status IN :statuses") List findMachineIdsByStatusIn(@Param("statuses") List statuses); - - boolean existsByUserAndStatusIn(User user, List statuses); } diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ShutdownIdleMachinesServiceImpl.java b/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ShutdownIdleMachinesServiceImpl.java index 1a2ecbec..71f30367 100644 --- a/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ShutdownIdleMachinesServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ShutdownIdleMachinesServiceImpl.java @@ -1,7 +1,6 @@ package team.washer.server.v2.domain.smartthings.service.impl; import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.Set; @@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; import team.washer.server.v2.domain.machine.entity.Machine; import team.washer.server.v2.domain.machine.repository.MachineRepository; -import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.ReservationRepository; import team.washer.server.v2.domain.smartthings.exception.SmartThingsPermissionException; import team.washer.server.v2.domain.smartthings.service.ShutdownIdleMachinesService; @@ -33,9 +31,6 @@ public class ShutdownIdleMachinesServiceImpl implements ShutdownIdleMachinesServ @Autowired(required = false) private DiscordErrorNotificationService discordErrorNotificationService; - private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED, - ReservationStatus.RUNNING); - @Override public void execute() { var machines = machineRepository.findAll(); @@ -43,7 +38,8 @@ public void execute() { return; } - var activeMachineIds = Set.copyOf(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)); + // 만료 예약만 남은 기기는 실제로 아무도 쓰지 않으므로 유휴 전원 차단 대상에 포함한다 + var activeMachineIds = Set.copyOf(reservationRepository.findCurrentlyActiveMachineIds()); var idleCandidates = machines.stream().filter(machine -> !activeMachineIds.contains(machine.getId())).toList(); var skippedActiveCount = machines.size() - idleCandidates.size(); diff --git a/src/main/java/team/washer/server/v2/domain/user/service/impl/DeleteUserServiceImpl.java b/src/main/java/team/washer/server/v2/domain/user/service/impl/DeleteUserServiceImpl.java index 0ced157e..6c4d3a8e 100644 --- a/src/main/java/team/washer/server/v2/domain/user/service/impl/DeleteUserServiceImpl.java +++ b/src/main/java/team/washer/server/v2/domain/user/service/impl/DeleteUserServiceImpl.java @@ -1,14 +1,11 @@ package team.washer.server.v2.domain.user.service.impl; -import java.util.List; - import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import team.themoment.sdk.exception.ExpectedException; -import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.ReservationRepository; import team.washer.server.v2.domain.user.repository.UserRepository; import team.washer.server.v2.domain.user.service.DeleteUserService; @@ -29,9 +26,8 @@ public void execute(Long userId) { final var user = userRepository.findById(userId) .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND)); - // 활성 예약이 있는지 확인 - final var activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING); - final boolean hasActiveReservations = reservationRepository.existsByUserAndStatusIn(user, activeStatuses); + // 만료되지 않은 활성 예약이 있는지 확인. 만료 예약만 남은 사용자는 본인 탈퇴와 동일하게 삭제할 수 있어야 한다 + final boolean hasActiveReservations = reservationRepository.existsCurrentlyActiveByUser(user); if (hasActiveReservations) { throw new ExpectedException("활성 예약이 있는 사용자는 삭제할 수 없습니다", HttpStatus.BAD_REQUEST); diff --git a/src/test/java/team/washer/server/v2/domain/admin/service/QueryAdminDashboardServiceTest.java b/src/test/java/team/washer/server/v2/domain/admin/service/QueryAdminDashboardServiceTest.java index da512a6d..9ca9602a 100644 --- a/src/test/java/team/washer/server/v2/domain/admin/service/QueryAdminDashboardServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/admin/service/QueryAdminDashboardServiceTest.java @@ -46,7 +46,7 @@ class ExecuteTest { @DisplayName("활성 예약, 고장 신고, 기기, 세탁정지 학생 통계를 성공적으로 조회한다") void execute_ShouldReturnDashboardStatistics_WhenDataExists() { // Given - when(reservationRepository.countActiveReservations()).thenReturn(5L); + when(reservationRepository.countCurrentlyActive()).thenReturn(5L); when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.PENDING)).thenReturn(3L); when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.IN_PROGRESS)).thenReturn(2L); when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.RESOLVED)).thenReturn(10L); @@ -72,7 +72,7 @@ void execute_ShouldReturnDashboardStatistics_WhenDataExists() { @DisplayName("데이터가 없으면 모든 통계가 0으로 반환된다") void execute_ShouldReturnZeroStatistics_WhenNoDataExists() { // Given - when(reservationRepository.countActiveReservations()).thenReturn(0L); + when(reservationRepository.countCurrentlyActive()).thenReturn(0L); when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.PENDING)).thenReturn(0L); when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.IN_PROGRESS)).thenReturn(0L); when(malfunctionReportRepository.countByStatus(MalfunctionReportStatus.RESOLVED)).thenReturn(0L); diff --git a/src/test/java/team/washer/server/v2/domain/machine/service/DeleteMachineServiceTest.java b/src/test/java/team/washer/server/v2/domain/machine/service/DeleteMachineServiceTest.java index 22ebb59e..489ecddf 100644 --- a/src/test/java/team/washer/server/v2/domain/machine/service/DeleteMachineServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/machine/service/DeleteMachineServiceTest.java @@ -62,7 +62,8 @@ void it_deletes_machine_and_returns_info() { var machineId = 1L; var machine = createMachine(); given(machineRepository.findById(machineId)).willReturn(Optional.of(machine)); - given(reservationRepository.findActiveReservationByMachineId(machineId)).willReturn(Optional.empty()); + given(reservationRepository.findCurrentlyActiveReservationByMachineId(machineId)) + .willReturn(Optional.empty()); // When var result = deleteMachineService.execute(machineId); @@ -85,7 +86,7 @@ void it_throws_bad_request_exception() { var machineId = 1L; var machine = createMachine(); given(machineRepository.findById(machineId)).willReturn(Optional.of(machine)); - given(reservationRepository.findActiveReservationByMachineId(machineId)) + given(reservationRepository.findCurrentlyActiveReservationByMachineId(machineId)) .willReturn(Optional.of(activeReservation)); // When & Then diff --git a/src/test/java/team/washer/server/v2/domain/machine/service/QueryAllMachinesStatusServiceTest.java b/src/test/java/team/washer/server/v2/domain/machine/service/QueryAllMachinesStatusServiceTest.java index 5e305105..4f9f8514 100644 --- a/src/test/java/team/washer/server/v2/domain/machine/service/QueryAllMachinesStatusServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/machine/service/QueryAllMachinesStatusServiceTest.java @@ -98,7 +98,7 @@ void execute_ShouldReturnSortedMachinesStatus_WhenSortedIsTrue() { when(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1", "device-2"))) .thenReturn(Map.of("device-1", deviceStatus, "device-2", deviceStatus)); - when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.empty()); + when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())).thenReturn(Optional.empty()); // When var result = queryAllMachinesStatusService.execute(USER_ID, true); @@ -122,7 +122,7 @@ void execute_ShouldReturnUnsortedMachinesStatus_WhenSortedIsFalse() { when(machineRepository.findAll()).thenReturn(List.of(machine1)); when(deviceStatusQuerySupport.queryAllDevicesStatus(any())).thenReturn(Map.of()); - when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.empty()); + when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())).thenReturn(Optional.empty()); // When var result = queryAllMachinesStatusService.execute(USER_ID, false); @@ -174,7 +174,8 @@ void execute_ShouldUseDryerCompletionTime_WhenDryerStatusContainsWasherAndDryerC when(machineRepository.findAll(any(Sort.class))).thenReturn(List.of(machine)); when(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1"))) .thenReturn(Map.of("device-1", deviceStatus)); - when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.of(reservation)); + when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())) + .thenReturn(Optional.of(reservation)); when(reservation.getStatus()).thenReturn(ReservationStatus.RUNNING); when(reservation.getUser()).thenReturn(user); @@ -211,7 +212,8 @@ void execute_ShouldKeepReservationInfo_WhenDeviceReportsCompletionButReservation when(machineRepository.findAll(any(Sort.class))).thenReturn(List.of(machine)); when(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1"))) .thenReturn(Map.of("device-1", deviceStatus)); - when(reservationRepository.findActiveReservationByMachineId(any())).thenReturn(Optional.of(reservation)); + when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())) + .thenReturn(Optional.of(reservation)); when(reservation.getId()).thenReturn(10L); when(reservation.getStatus()).thenReturn(ReservationStatus.RUNNING); when(reservation.getUser()).thenReturn(user); @@ -272,7 +274,7 @@ private void givenMachineWithReservation(Machine machine, Reservation reservatio givenUserMocked(); when(machineRepository.findAll(any(Sort.class))).thenReturn(List.of(machine)); when(deviceStatusQuerySupport.queryAllDevicesStatus(any())).thenReturn(Map.of()); - when(reservationRepository.findActiveReservationByMachineId(any())) + when(reservationRepository.findCurrentlyActiveReservationByMachineId(any())) .thenReturn(Optional.ofNullable(reservationOrNull)); } diff --git a/src/test/java/team/washer/server/v2/domain/machine/service/UpdateMachineStatusServiceTest.java b/src/test/java/team/washer/server/v2/domain/machine/service/UpdateMachineStatusServiceTest.java index 9de919c5..25d5ba4e 100644 --- a/src/test/java/team/washer/server/v2/domain/machine/service/UpdateMachineStatusServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/machine/service/UpdateMachineStatusServiceTest.java @@ -102,7 +102,7 @@ void it_changes_machine_to_normal_and_available() { given(machine.getId()).willReturn(machineId); given(machineRepository.findById(machineId)).willReturn(Optional.of(machine)); - given(reservationRepository.findActiveReservationByMachineId(machineId)) + given(reservationRepository.findCurrentlyActiveReservationByMachineId(machineId)) .willReturn(Optional.empty()); given(machineRepository.save(any(Machine.class))) .willAnswer(invocation -> invocation.getArgument(0)); @@ -115,7 +115,7 @@ void it_changes_machine_to_normal_and_available() { assertThat(result.status()).isEqualTo(MachineStatus.NORMAL); assertThat(result.availability()).isEqualTo(MachineAvailability.AVAILABLE); then(machineRepository).should(times(1)).findById(machineId); - then(reservationRepository).should(times(1)).findActiveReservationByMachineId(machineId); + then(reservationRepository).should(times(1)).findCurrentlyActiveReservationByMachineId(machineId); then(machineRepository).should(times(1)).save(any(Machine.class)); } } @@ -134,7 +134,7 @@ void it_changes_machine_to_normal_and_reserved() { Reservation reservation = createReservation(machine, ReservationStatus.RESERVED); given(machineRepository.findById(machineId)).willReturn(Optional.of(machine)); - given(reservationRepository.findActiveReservationByMachineId(machineId)) + given(reservationRepository.findCurrentlyActiveReservationByMachineId(machineId)) .willReturn(Optional.of(reservation)); given(machineRepository.save(any(Machine.class))) .willAnswer(invocation -> invocation.getArgument(0)); @@ -147,7 +147,7 @@ void it_changes_machine_to_normal_and_reserved() { assertThat(result.status()).isEqualTo(MachineStatus.NORMAL); assertThat(result.availability()).isEqualTo(MachineAvailability.RESERVED); then(machineRepository).should(times(1)).findById(machineId); - then(reservationRepository).should(times(1)).findActiveReservationByMachineId(machineId); + then(reservationRepository).should(times(1)).findCurrentlyActiveReservationByMachineId(machineId); then(machineRepository).should(times(1)).save(any(Machine.class)); } } 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 index d649ed91..cf4ab200 100644 --- 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 @@ -50,7 +50,9 @@ class ReservationRepositoryCurrentlyActiveTest { private TestEntityManager entityManager; private User owner; + private User expiredOnlyOwner; private Machine washer; + private Machine expiredOnlyMachine; private Reservation freshReserved; private Reservation justBeforeTimeoutReserved; @@ -83,6 +85,14 @@ void setUp() { completed = persistReservation(ReservationStatus.COMPLETED, now.minusMinutes(1)); cancelled = persistReservation(ReservationStatus.CANCELLED, now.minusMinutes(1)); + // 만료된 RESERVED 예약만 남은 사용자와 기기. 스케줄러가 아직 정리하지 못한 상태를 재현한다 + expiredOnlyOwner = persist( + User.builder().name("이건조").studentId("2102").roomNumber("302").grade(1).floor(3).build()); + expiredOnlyMachine = persist(Machine.builder().name("W3R1").type(MachineType.WASHER).deviceId("device-w3r1") + .floor(3).position(Position.RIGHT).number(1).build()); + persist(Reservation.builder().user(expiredOnlyOwner).machine(expiredOnlyMachine) + .status(ReservationStatus.RESERVED).reservedAt(now.minusMinutes(TIMEOUT_MINUTES + 30)).build()); + entityManager.flush(); entityManager.clear(); } @@ -120,6 +130,81 @@ class FindCurrentlyActiveByRoomNumber { } } + @Nested + @DisplayName("findCurrentlyActiveByMachineId 메서드는") + class FindCurrentlyActiveByMachineId { + + @Test + @DisplayName("만료되지 않은 활성 예약만 반환한다") + void 만료되지_않은_활성_예약만_반환한다() { + assertActiveIdsAre(() -> reservationRepository.findCurrentlyActiveByMachineId(washer.getId())); + } + + @Test + @DisplayName("만료 예약만 남은 기기에는 빈 목록을 반환한다") + void 만료_예약만_남은_기기에는_빈_목록을_반환한다() { + assertThat(reservationRepository.findCurrentlyActiveByMachineId(expiredOnlyMachine.getId())).isEmpty(); + } + } + + @Nested + @DisplayName("findCurrentlyActiveReservationByMachineId 메서드는") + class FindCurrentlyActiveReservationByMachineId { + + @Test + @DisplayName("RUNNING 예약을 대표 예약으로 반환한다") + void RUNNING_예약을_대표_예약으로_반환한다() { + assertThat(reservationRepository.findCurrentlyActiveReservationByMachineId(washer.getId())).get() + .extracting(Reservation::getId).isEqualTo(longRunning.getId()); + } + + @Test + @DisplayName("만료 예약만 남은 기기에는 빈 값을 반환한다") + void 만료_예약만_남은_기기에는_빈_값을_반환한다() { + assertThat(reservationRepository.findCurrentlyActiveReservationByMachineId(expiredOnlyMachine.getId())) + .isEmpty(); + } + } + + @Nested + @DisplayName("findCurrentlyActiveMachineIds 메서드는") + class FindCurrentlyActiveMachineIds { + + @Test + @DisplayName("만료 예약만 남은 기기를 제외한 기기 ID만 반환한다") + void 만료_예약만_남은_기기를_제외한_기기_ID만_반환한다() { + assertThat(reservationRepository.findCurrentlyActiveMachineIds()).containsExactly(washer.getId()); + } + } + + @Nested + @DisplayName("countCurrentlyActive 메서드는") + class CountCurrentlyActive { + + @Test + @DisplayName("만료 예약을 제외한 활성 예약 수를 반환한다") + void 만료_예약을_제외한_활성_예약_수를_반환한다() { + assertThat(reservationRepository.countCurrentlyActive()).isEqualTo(4L); + } + } + + @Nested + @DisplayName("existsCurrentlyActiveByUser 메서드는") + class ExistsCurrentlyActiveByUser { + + @Test + @DisplayName("만료되지 않은 활성 예약이 있으면 참을 반환한다") + void 만료되지_않은_활성_예약이_있으면_참을_반환한다() { + assertThat(reservationRepository.existsCurrentlyActiveByUser(owner)).isTrue(); + } + + @Test + @DisplayName("만료 예약만 남은 사용자에게는 거짓을 반환한다") + void 만료_예약만_남은_사용자에게는_거짓을_반환한다() { + assertThat(reservationRepository.existsCurrentlyActiveByUser(expiredOnlyOwner)).isFalse(); + } + } + @Nested @DisplayName("쿼리 조건과 엔티티 판정은") class QueryAndEntityRule { diff --git a/src/test/java/team/washer/server/v2/domain/smartthings/service/ShutdownIdleMachinesServiceTest.java b/src/test/java/team/washer/server/v2/domain/smartthings/service/ShutdownIdleMachinesServiceTest.java index a48e758e..9fd35bd5 100644 --- a/src/test/java/team/washer/server/v2/domain/smartthings/service/ShutdownIdleMachinesServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/smartthings/service/ShutdownIdleMachinesServiceTest.java @@ -23,7 +23,6 @@ import team.washer.server.v2.domain.machine.enums.MachineType; import team.washer.server.v2.domain.machine.enums.Position; import team.washer.server.v2.domain.machine.repository.MachineRepository; -import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.ReservationRepository; import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto; import team.washer.server.v2.domain.smartthings.exception.SmartThingsPermissionException; @@ -51,9 +50,6 @@ class ShutdownIdleMachinesServiceTest { @Mock private DeviceShutdownSupport deviceShutdownSupport; - private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED, - ReservationStatus.RUNNING); - private static final SmartThingsDeviceStatusResDto EMPTY_STATUS = new SmartThingsDeviceStatusResDto(Map.of()); private Machine createMachine(final Long id, final String name, final String deviceId) { @@ -97,7 +93,7 @@ void it_delegates_safe_shutdown() { // Given var machine = createMachine(1L, "W-2F-L1", "device-1"); given(machineRepository.findAll()).willReturn(List.of(machine)); - given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of()); + given(reservationRepository.findCurrentlyActiveMachineIds()).willReturn(List.of()); given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1"))) .willReturn(Map.of("device-1", EMPTY_STATUS)); given(deviceShutdownSupport.shutdown(eq(machine), any())).willReturn(ShutdownResult.POWERED_OFF); @@ -120,7 +116,7 @@ void it_skips_reserved_machine() { // Given var machine = createMachine(1L, "W-2F-L1", "device-1"); given(machineRepository.findAll()).willReturn(List.of(machine)); - given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of(1L)); + given(reservationRepository.findCurrentlyActiveMachineIds()).willReturn(List.of(1L)); // When shutdownIdleMachinesService.execute(); @@ -141,7 +137,7 @@ void it_safely_stops_without_power_off() { // Given var machine = createMachine(1L, "W-2F-L1", "device-1"); given(machineRepository.findAll()).willReturn(List.of(machine)); - given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of()); + given(reservationRepository.findCurrentlyActiveMachineIds()).willReturn(List.of()); given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1"))) .willReturn(Map.of("device-1", EMPTY_STATUS)); given(deviceShutdownSupport.shutdown(eq(machine), any())).willReturn(ShutdownResult.STOPPED); @@ -165,7 +161,7 @@ void it_stops_batch_on_permission_error() { var machine1 = createMachine(1L, "W-2F-L1", "device-1"); var machine2 = createMachine(2L, "W-2F-R1", "device-2"); given(machineRepository.findAll()).willReturn(List.of(machine1, machine2)); - given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of()); + given(reservationRepository.findCurrentlyActiveMachineIds()).willReturn(List.of()); given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1", "device-2"))) .willReturn(Map.of("device-1", EMPTY_STATUS, "device-2", EMPTY_STATUS)); willThrow(new SmartThingsPermissionException("권한 없음")).given(deviceShutdownSupport) @@ -191,7 +187,7 @@ void it_continues_processing_after_failure() { var machine1 = createMachine(1L, "W-2F-L1", "device-1"); var machine2 = createMachine(2L, "W-2F-R1", "device-2"); given(machineRepository.findAll()).willReturn(List.of(machine1, machine2)); - given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of()); + given(reservationRepository.findCurrentlyActiveMachineIds()).willReturn(List.of()); given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1", "device-2"))) .willReturn(Map.of("device-1", EMPTY_STATUS, "device-2", EMPTY_STATUS)); willThrow(new RuntimeException("일시적 오류")).given(deviceShutdownSupport).shutdown(eq(machine1), any()); diff --git a/src/test/java/team/washer/server/v2/domain/user/service/DeleteUserServiceTest.java b/src/test/java/team/washer/server/v2/domain/user/service/DeleteUserServiceTest.java index 81926120..b4f8f5a2 100644 --- a/src/test/java/team/washer/server/v2/domain/user/service/DeleteUserServiceTest.java +++ b/src/test/java/team/washer/server/v2/domain/user/service/DeleteUserServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -16,7 +15,6 @@ import org.springframework.http.HttpStatus; import team.themoment.sdk.exception.ExpectedException; -import team.washer.server.v2.domain.reservation.enums.ReservationStatus; import team.washer.server.v2.domain.reservation.repository.ReservationRepository; import team.washer.server.v2.domain.user.entity.User; import team.washer.server.v2.domain.user.repository.UserRepository; @@ -54,17 +52,15 @@ void it_deletes_user() { // Given Long userId = 1L; User user = createUser(); - List activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(reservationRepository.existsByUserAndStatusIn(user, activeStatuses)).willReturn(false); + given(reservationRepository.existsCurrentlyActiveByUser(user)).willReturn(false); // When deleteUserService.execute(userId); // Then then(userRepository).should(times(1)).findById(userId); - then(reservationRepository).should(times(1)).existsByUserAndStatusIn(user, activeStatuses); + then(reservationRepository).should(times(1)).existsCurrentlyActiveByUser(user); then(userRepository).should(times(1)).delete(user); } } @@ -79,10 +75,8 @@ void it_throws_expected_exception() { // Given Long userId = 1L; User user = createUser(); - List activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(reservationRepository.existsByUserAndStatusIn(user, activeStatuses)).willReturn(true); + given(reservationRepository.existsCurrentlyActiveByUser(user)).willReturn(true); // When & Then assertThatThrownBy(() -> deleteUserService.execute(userId)).isInstanceOf(ExpectedException.class) @@ -90,7 +84,7 @@ void it_throws_expected_exception() { .hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST); then(userRepository).should(times(1)).findById(userId); - then(reservationRepository).should(times(1)).existsByUserAndStatusIn(user, activeStatuses); + then(reservationRepository).should(times(1)).existsCurrentlyActiveByUser(user); then(userRepository).should(never()).delete(any(User.class)); } } @@ -105,10 +99,8 @@ void it_throws_expected_exception() { // Given Long userId = 1L; User user = createUser(); - List activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(reservationRepository.existsByUserAndStatusIn(user, activeStatuses)).willReturn(true); + given(reservationRepository.existsCurrentlyActiveByUser(user)).willReturn(true); // When & Then assertThatThrownBy(() -> deleteUserService.execute(userId)).isInstanceOf(ExpectedException.class) @@ -116,7 +108,7 @@ void it_throws_expected_exception() { .hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST); then(userRepository).should(times(1)).findById(userId); - then(reservationRepository).should(times(1)).existsByUserAndStatusIn(user, activeStatuses); + then(reservationRepository).should(times(1)).existsCurrentlyActiveByUser(user); then(userRepository).should(never()).delete(any(User.class)); } } @@ -131,17 +123,15 @@ void it_deletes_user() { // Given Long userId = 1L; User user = createUser(); - List activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(reservationRepository.existsByUserAndStatusIn(user, activeStatuses)).willReturn(false); + given(reservationRepository.existsCurrentlyActiveByUser(user)).willReturn(false); // When deleteUserService.execute(userId); // Then then(userRepository).should(times(1)).findById(userId); - then(reservationRepository).should(times(1)).existsByUserAndStatusIn(user, activeStatuses); + then(reservationRepository).should(times(1)).existsCurrentlyActiveByUser(user); then(userRepository).should(times(1)).delete(user); } } @@ -156,17 +146,38 @@ void it_deletes_user() { // Given Long userId = 1L; User user = createUser(); - List activeStatuses = List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(reservationRepository.existsByUserAndStatusIn(user, activeStatuses)).willReturn(false); + given(reservationRepository.existsCurrentlyActiveByUser(user)).willReturn(false); // When deleteUserService.execute(userId); // Then then(userRepository).should(times(1)).findById(userId); - then(reservationRepository).should(times(1)).existsByUserAndStatusIn(user, activeStatuses); + then(reservationRepository).should(times(1)).existsCurrentlyActiveByUser(user); + then(userRepository).should(times(1)).delete(user); + } + } + + @Nested + @DisplayName("타임아웃이 지난 RESERVED 예약만 남은 사용자를 삭제할 때") + class Context_with_expired_reservations_only { + + @Test + @DisplayName("사용자를 삭제해야 한다") + void it_deletes_user() { + // Given + Long userId = 1L; + User user = createUser(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(reservationRepository.existsCurrentlyActiveByUser(user)).willReturn(false); + + // When + deleteUserService.execute(userId); + + // Then + then(reservationRepository).should(times(1)).existsCurrentlyActiveByUser(user); then(userRepository).should(times(1)).delete(user); } } @@ -188,7 +199,7 @@ void it_throws_expected_exception() { .hasMessage("사용자를 찾을 수 없습니다").hasFieldOrPropertyWithValue("statusCode", HttpStatus.NOT_FOUND); then(userRepository).should(times(1)).findById(userId); - then(reservationRepository).should(never()).existsByUserAndStatusIn(any(User.class), anyList()); + then(reservationRepository).should(never()).existsCurrentlyActiveByUser(any(User.class)); then(userRepository).should(never()).delete(any(User.class)); } }