diff --git a/docs/spec-admin-penalty.md b/docs/spec-admin-penalty.md
new file mode 100644
index 00000000..c0f20ea4
--- /dev/null
+++ b/docs/spec-admin-penalty.md
@@ -0,0 +1,401 @@
+# 구현 스펙: 관리자 세탁 패널티 부과 기능
+
+## 1. 기능 요약
+
+관리자 또는 기숙사자치위원회가 특정 사용자를 지목해 **48시간 세탁 패널티(호실 예약 차단)** 를 직접 부과하는 기능.
+
+기존 패널티 정책은 두 가지였다.
+
+1. 예약 직접 취소 또는 예약 기간 만료 시 → 해당 기기 유형에 **5분 쿨다운**
+2. 48시간 이내 5분 패널티가 5회 누적되면 → 해당 호실에 **48시간 예약 차단**
+
+여기에 세 번째 정책을 추가한다.
+
+3. **관리자·자치위가 패널티를 부과하면 5분 패널티 5회 누적과 동일하게 취급 → 즉시 48시간 호실 차단**
+
+정책 문서상으로는 "5분 5회 누적"으로 설명하지만, 구현은 취소 이력을 조작하지 않고 **48시간 차단을 직접 부여**한다(§7-1 참고).
+
+---
+
+## 2. 확정 요구사항
+
+| 항목 | 결정 |
+|------|------|
+| 구현 모델 | 직접 부여형 — 취소 이력 ZSet을 건드리지 않고 `applyBlock()` 직접 호출 |
+| 차단 단위 | **호실(roomNumber)** 단위 — 기존 48시간 차단과 동일 |
+| 부과 권한 | **자치위(DORMITORY_COUNCIL) + 관리자(ADMIN)** — 서비스 내 `isAdmin()` 검사 없음 |
+| 해제·연장 권한 | 기존 그대로 **ADMIN만** (비대칭 유지, 변경 없음) |
+| 이미 차단 중일 때 | TTL을 **48시간으로 리셋**(덮어쓰기), 거부하지 않음 |
+| 부과 사유(reason) | **필수** 입력 |
+| 사유 저장 위치 | 별도 엔티티 없음 — 알림 메시지(DB) + 서버 로그 |
+| 진행 중 예약 | **건드리지 않음** — 신규 예약 생성만 차단됨 |
+| 대상 가드 | **자기 자신 지목 금지** (호실 단위 가드는 두지 않음) |
+| 5분 쿨다운 | 함께 적용하지 **않음** |
+| 알림 | **신규 타입** `ADMIN_PENALTY_BLOCKED` 추가, 차단 중이어도 **항상 발송** |
+| Redis 실패 시 | `applyBlockOrThrow()`로 저장 실패를 **예외 전파**(알림 미발송) |
+
+---
+
+## 3. API 설계
+
+```
+POST /api/v2/admin/reservations/users/{userId}/penalty
+```
+
+기존 해제 API(`DELETE /api/v2/admin/reservations/users/{userId}/penalty`)와 동일 URI에서 부과/해제가 짝을 이룬다.
+
+### Request
+
+```json
+{
+ "reason": "세탁물 장기 방치로 기기 점유"
+}
+```
+
+### Response (성공)
+
+SDK가 자동 래핑 → 바디 없음, `CommonApiResponse.success("세탁 패널티가 부과되었습니다.")` 반환
+
+### Error Cases
+
+| 조건 | 상태 코드 | 메시지 |
+|------|-----------|--------|
+| 일반 사용자(USER) 접근 | 403 | Spring Security가 차단 (`/api/v2/admin/**`) |
+| 자기 자신에게 부과 | 400 | 자신에게는 패널티를 부과할 수 없습니다. |
+| 사유 누락·공백 | 400 | 부과 사유는 필수입니다 |
+| 사유 200자 초과 | 400 | 부과 사유는 200자를 초과할 수 없습니다 |
+| actor userId 없음 | 404 | 사용자를 찾을 수 없습니다. |
+| 대상 userId 없음 | 404 | 사용자를 찾을 수 없습니다. |
+| 대상 호실 정보 없음 | 404 | 호실 정보를 찾을 수 없습니다. |
+| Redis 차단 부여 실패 | 500 | 패널티 부과에 실패했습니다. 잠시 후 다시 시도해 주세요. |
+
+### 권한 처리 방식
+
+`DomainAuthorizationConfig:43`이 이미 `/api/v2/admin/**`를 `DORMITORY_COUNCIL`, `ADMIN`에 허용한다.
+따라서 **서비스에 `isAdmin()` 검사를 넣지 않는 것만으로** 자치위 부과가 가능해진다.
+`ClearUserPenaltyServiceImpl:32`, `ExtendCancellationBlockServiceImpl:36`의 `isAdmin()` 검사는 **그대로 둔다**(해제·연장은 ADMIN 전용 유지).
+
+---
+
+## 4. 구현 대상 파일 목록
+
+### 신규 생성
+
+| 파일 | 역할 |
+|------|------|
+| `domain/reservation/dto/request/ApplyUserPenaltyReqDto.java` | 요청 DTO (reason 필드) |
+| `domain/reservation/service/ApplyUserPenaltyService.java` | 서비스 인터페이스 |
+| `domain/reservation/service/impl/ApplyUserPenaltyServiceImpl.java` | 서비스 구현체 |
+
+### 수정
+
+| 파일 | 변경 내용 |
+|------|-----------|
+| `domain/notification/enums/NotificationType.java` | `ADMIN_PENALTY_BLOCKED` 상수 추가 |
+| `domain/notification/entity/Notification.java` | `createAdminPenaltyNotification()` 정적 팩토리 추가, `type` 컬럼 길이 `20` → `50` |
+| `domain/notification/support/ReservationNotificationSupport.java` | `sendAdminPenalty()` 추가 |
+| `domain/reservation/controller/AdminReservationController.java` | POST 엔드포인트 추가 |
+
+### PenaltyRedisUtil 변경
+
+`applyBlockOrThrow(String)`를 **추가**하고, 기존 `applyBlock()`은 이를 호출한 뒤 예외를 삼키도록 위임한다.
+저장 로직이 한 곳에만 남고 자동 판정 경로(`CancelReservationServiceImpl`, `OverdueReservationProcessor`)의 동작은 그대로 유지된다.
+
+> 초기 설계는 `applyBlock()` 호출 후 `isBlocked()`로 성공을 판정했으나 **false positive**가 있어 변경했다.
+> 이미 차단 중인 호실에서 TTL 갱신이 실패하면 남아 있는 키 때문에 `isBlocked()`가 `true`를 반환하고,
+> "TTL 48시간 갱신"이 수행되지 않았는데도 성공 응답과 알림이 나간다. (PR #121 리뷰 지적)
+
+---
+
+## 5. 상세 구현
+
+### 5-1. ApplyUserPenaltyReqDto
+
+```java
+package team.washer.server.v2.domain.reservation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+@Schema(description = "세탁 패널티 부과 요청 DTO")
+public record ApplyUserPenaltyReqDto(
+ @NotBlank(message = "부과 사유는 필수입니다") @Size(max = 200, message = "부과 사유는 200자를 초과할 수 없습니다") @Schema(description = "패널티 부과 사유", example = "세탁물 장기 방치로 기기 점유") String reason) {
+}
+```
+
+`@Size(max = 200)`은 `CreateMalfunctionReportReqDto:11` 선례를 따른다.
+`Notification.message` 컬럼이 500자 제한(`Notification.java:42`)이고 템플릿 고정부가 약 40자이므로 안전하다.
+
+### 5-2. ApplyUserPenaltyService
+
+```java
+package team.washer.server.v2.domain.reservation.service;
+
+public interface ApplyUserPenaltyService {
+
+ /**
+ * 대상 사용자의 호실에 48시간 세탁 패널티를 부과합니다.
+ *
+ * @param userId
+ * 패널티 부과 대상 사용자 ID
+ * @param reason
+ * 부과 사유
+ */
+ void execute(Long userId, String reason);
+}
+```
+
+### 5-3. ApplyUserPenaltyServiceImpl
+
+```java
+package team.washer.server.v2.domain.reservation.service.impl;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import team.themoment.sdk.exception.ExpectedException;
+import team.washer.server.v2.domain.notification.support.ReservationNotificationSupport;
+import team.washer.server.v2.domain.reservation.service.ApplyUserPenaltyService;
+import team.washer.server.v2.domain.reservation.util.PenaltyRedisUtil;
+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;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ApplyUserPenaltyServiceImpl implements ApplyUserPenaltyService {
+
+ private final UserRepository userRepository;
+ private final PenaltyRedisUtil penaltyRedisUtil;
+ private final ReservationNotificationSupport reservationNotificationSupport;
+ private final CurrentUserProvider currentUserProvider;
+
+ @Override
+ @Transactional
+ public void execute(final Long userId, final String reason) {
+ final var actorId = currentUserProvider.getCurrentUserId();
+
+ // 권한 검사는 SecurityConfig가 담당한다(DORMITORY_COUNCIL, ADMIN 허용).
+ // 자치위·관리자 구분은 감사 로그에만 남기므로 actor는 조회만 한다.
+ final User actor = userRepository.findById(actorId)
+ .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
+
+ if (actorId.equals(userId)) {
+ throw new ExpectedException("자신에게는 패널티를 부과할 수 없습니다.", HttpStatus.BAD_REQUEST);
+ }
+
+ final User target = userRepository.findById(userId)
+ .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
+
+ final String roomNumber = target.getRoomNumber();
+ if (roomNumber == null) {
+ throw new ExpectedException("호실 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND);
+ }
+
+ // 관리자에게 거짓 성공 응답이 나가면 제재가 집행되지 않은 채 종료되므로 저장 실패를 예외로 받는다.
+ // isBlocked로 판정하면 이미 차단 중인 호실에서 TTL 갱신 실패를 성공으로 오인한다.
+ try {
+ penaltyRedisUtil.applyBlockOrThrow(roomNumber);
+ } catch (Exception e) {
+ log.error("failed to apply admin penalty roomNumber={} targetId={} actorId={}",
+ roomNumber,
+ userId,
+ actorId,
+ e);
+ throw new ExpectedException("패널티 부과에 실패했습니다. 잠시 후 다시 시도해 주세요.",
+ HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+
+ log.info("admin penalty applied roomNumber={} targetId={} actorId={} actorRole={} reason={}",
+ roomNumber,
+ userId,
+ actorId,
+ actor.getRole(),
+ reason);
+
+ reservationNotificationSupport.sendAdminPenalty(target, reason);
+ }
+}
+```
+
+**설계 노트**
+
+- `isAdmin()` 검사를 넣지 않는 것이 자치위 허용의 핵심이다. 실수로 추가하지 말 것.
+- `applyBlockOrThrow()`의 예외를 잡아 `ExpectedException`으로 바꾼다. 실패 시 알림이 발송되지 않는다.
+- Redis는 트랜잭션 롤백 대상이 아니므로, 알림 저장이 실패해도 차단은 남는다(제재가 남는 쪽이 안전한 실패 방향).
+- 로그가 부과자 추적의 유일한 수단이므로 `actorId`, `actorRole`, `reason`을 모두 남긴다.
+
+### 5-4. NotificationType 상수 추가
+
+`NotificationType.java`의 `FORCE_STOPPED` 뒤에 추가한다.
+
+```java
+ADMIN_PENALTY_BLOCKED("예약 차단 알림", "관리자에 의해 해당 호실의 예약이 48시간 동안 제한됩니다.\n\n사유: {reason}")
+```
+
+> **컬럼 길이 주의**: `notifications.type`은 `@Enumerated(EnumType.STRING)` + `length = 20`이었고 `ddl-auto: update`라 실제 `varchar(20)`이 생성된다.
+> `ADMIN_PENALTY_BLOCKED`는 21자라 저장 시 잘리거나 MySQL strict mode에서 Data truncation이 발생한다.
+> 다만 이 문제는 이번 기능이 만든 것이 아니다 — 기존 `CANCELLATION_BLOCK_EXTENDED`가 이미 **27자**로 한계를 넘고 있었다.
+> 최장 27자에 여유를 두어 `length = 50`으로 넓혀 기존 버그까지 함께 해소한다. 컬럼 확장이므로 `ddl-auto: update`가 `ALTER`로 안전하게 처리한다. (PR #121 리뷰 지적)
+
+> **주의**: `formatMessage(String reason)` 오버로드는 **추가할 수 없다.**
+> 이미 `formatMessage(String machineName)`이 존재해 시그니처가 충돌한다(`NotificationType.java:39`).
+> 대신 팩토리에서 `getMessageTemplate().replace("{reason}", reason)`를 직접 호출한다 —
+> `createWarningNotification`(`Notification.java:158`)이 같은 방식을 쓴다.
+
+### 5-5. Notification 정적 팩토리
+
+```java
+/**
+ * 관리자 패널티 부과 알림을 생성합니다.
+ *
+ * @param user
+ * 알림 수신 사용자
+ * @param reason
+ * 패널티 부과 사유
+ * @return 생성된 관리자 패널티 알림
+ */
+public static Notification createAdminPenaltyNotification(User user, String reason) {
+ String message = NotificationType.ADMIN_PENALTY_BLOCKED.getMessageTemplate().replace("{reason}", reason);
+
+ return Notification.builder().user(user).type(NotificationType.ADMIN_PENALTY_BLOCKED).message(message)
+ .isRead(false).build();
+}
+```
+
+`machine`을 설정하지 않는다. `createBlockExtensionNotification`(`Notification.java:227`)이 동일한 선례다.
+
+### 5-6. ReservationNotificationSupport
+
+```java
+/**
+ * 관리자 패널티 부과 알림을 전송한다.
+ */
+@Transactional
+public void sendAdminPenalty(User user, String reason) {
+ var notification = Notification.createAdminPenaltyNotification(user, reason);
+ persistAndSend(user, notification, "예약 차단 알림");
+}
+```
+
+### 5-7. AdminReservationController
+
+필드 추가:
+
+```java
+private final ApplyUserPenaltyService applyUserPenaltyService;
+```
+
+엔드포인트 추가 (기존 `clearUserPenalty` 바로 위에 배치):
+
+```java
+@PostMapping("/users/{userId}/penalty")
+@Operation(summary = "사용자 세탁 패널티 부과", description = "특정 사용자의 호실에 48시간 예약 차단을 부과합니다. 5분 패널티 5회 누적과 동일한 제재이며, 관리자와 기숙사자치위원회가 사용할 수 있습니다. 이미 차단 중인 호실은 차단 기간이 48시간으로 갱신됩니다.")
+public CommonApiResponse applyUserPenalty(@Parameter(description = "사용자 ID") @PathVariable @NotNull Long userId,
+ @Valid @RequestBody ApplyUserPenaltyReqDto reqDto) {
+
+ applyUserPenaltyService.execute(userId, reqDto.reason());
+ return CommonApiResponse.success("세탁 패널티가 부과되었습니다.");
+}
+```
+
+---
+
+## 6. 기존 기능과의 연동
+
+부과 결과가 기존 조회·해제 경로에 **자동 반영**된다. 추가 작업이 없다.
+
+| 경로 | 동작 |
+|------|------|
+| `CreateReservationServiceImpl:44` | `isBlocked(roomNumber)` → 신규 예약 차단됨 |
+| `QueryReservationAvailabilityServiceImpl:38` | 예약 가능 여부에 반영됨 |
+| `PenaltyStatusResDto.isRoomBlocked` / `blockExpiresAt` | 차단 상태·만료 시각 노출됨 |
+| `ClearUserPenaltyService` (ADMIN) | `clearAllRestrictions()`가 호실 차단까지 해제 |
+| `ExtendCancellationBlockService` (ADMIN) | 부과된 차단을 일 단위로 연장 가능 |
+
+`PenaltyStatusResDto`에는 **"관리자 부과인지 자동 누적인지" 구분이 나타나지 않는다.** Redis 차단 엔티티가 사유를 담지 않기 때문이며(결정 6), 사유는 사용자 알림 목록에서 확인한다.
+
+---
+
+## 7. 검토 후 기각한 대안
+
+### 7-1. 취소 이력 ZSet에 5건 주입 (이력 주입형)
+
+"5분 5회 누적으로 판단"을 문자 그대로 구현하는 방식. **기각.**
+48시간 차단이 만료돼도 ZSet 이력 5건이 남아, 사용자가 이후 정상적으로 **한 번만 취소해도 즉시 재차단**되는 숨은 함정이 생긴다. 관리자 재량 제재와 자연 누적 이력이 뒤섞여 추적도 흐려진다.
+
+### 7-2. 유저 단위 신규 차단 엔티티
+
+지목된 사용자만 정확히 차단. **기각.**
+"48시간 세탁 패널티"라는 동일한 이름의 서로 다른 두 메커니즘이 생기고, 조회·해제·연장 세 API가 전부 분기를 안게 된다. 기존 차단이 호실 단위인 이유(룸메이트 간 번갈아 예약하는 우회 방지)가 관리자 부과에도 동일하게 적용된다.
+
+### 7-3. 해제 권한도 자치위까지 완화
+
+자치위 오조작을 스스로 수습하게 하는 방식. **기각.**
+제재 부과는 넓게, 해제는 좁게 두는 것이 정상적인 권한 설계다. 또한 `ClearUserPenaltyServiceImpl` 완화는 **자연 누적 패널티 해제 권한까지 동시에 여는** 범위 초과 변경이다. 자치위 오조작은 관리자 요청이라는 운영 절차로 흡수한다.
+
+### 7-4. 진행 중 예약 연쇄 취소
+
+**기각.** `RUNNING` 상태를 취소하면 젖은 세탁물이 기기에 갇힌 채 예약만 사라진다. 그런 개입에는 이미 `ForceStopMachineService`와 `AdminCancelReservationService`가 있으므로 관리자가 판단해서 별도로 사용한다.
+
+### 7-5. 자기 호실(roomNumber) 부과 금지
+
+**기각.** 자기 호실에 부과하면 본인도 48시간 세탁을 못 하므로 남용 유인이 없고(자해), 오히려 룸메이트의 실제 위반을 가장 잘 아는 사람이 조치하지 못하게 된다. 자기 자신 지목만 오조작으로 보고 차단한다.
+
+### 7-6. 감사 전용 JPA 엔티티(`AdminPenalty`)
+
+**기각.** `ClearUserPenaltyServiceImpl:38`, `ExtendCancellationBlockServiceImpl:51` 모두 관리자 행위를 로그로만 남기는 기존 관례를 따른다. 사유는 `Notification.message`에 DB로 영구 보존되므로 실질 추적이 확보된다.
+**단, 부과자 신원(actorId·actorRole)은 애플리케이션 로그에만 남는다.** 로그 보존·검색 환경이 부실하다면 이 결정을 재검토할 것.
+
+---
+
+## 8. 테스트 계획
+
+`ApplyUserPenaltyServiceTest` 신규 작성. 기존 `ClearUserPenaltyServiceTest` 구조(BDD + `@Nested` + 한국어 `@DisplayName`)를 따른다.
+
+### 성공 케이스
+
+- 관리자가 부과하면 호실 차단이 적용되고 알림이 발송된다
+- **자치위원이 부과해도 정상 동작한다** (핵심 회귀 방지 — `isAdmin()` 검사가 실수로 추가되면 여기서 실패)
+
+### 실패 케이스
+
+- 자기 자신에게 부과하면 400
+- 존재하지 않는 부과자면 404
+- 존재하지 않는 대상 사용자면 404
+- 대상의 호실 정보가 없으면 404
+- `applyBlockOrThrow`가 예외를 던지면 500이고 **알림이 발송되지 않는다**
+- 이미 차단 중인 호실에서도 `isBlocked`를 성공 판정에 **사용하지 않는다**(false positive 회귀 방지)
+
+> **"이미 차단 중인 호실도 알림이 재발송된다"는 단위 테스트로 작성하지 않았다.**
+> 서비스가 사전 차단 여부(`wasBlocked`)를 **조회하지 않으므로** 차단 중이든 아니든 실행 경로가 완전히 동일하다.
+> 즉 이 동작은 "알림을 억제하는 분기가 코드에 없다"는 사실 자체로 보장되며, 목으로 구분할 상태가 존재하지 않는다.
+
+### 검증 테스트
+
+`ApplyUserPenaltyReqDtoTest` — `FcmTokenReqDtoTest` 선례를 따라 작성.
+
+- 사유 200자는 허용된다
+- 사유 200자 초과 시 검증 실패
+- 사유가 공백이면 검증 실패
+- 사유가 null이면 검증 실패
+
+---
+
+## 9. 작업 순서
+
+1. `NotificationType.ADMIN_PENALTY_BLOCKED` 추가
+2. `Notification.createAdminPenaltyNotification()` 추가
+3. `ReservationNotificationSupport.sendAdminPenalty()` 추가
+4. `ApplyUserPenaltyReqDto` 생성
+5. `ApplyUserPenaltyService` / `ApplyUserPenaltyServiceImpl` 생성
+6. `AdminReservationController`에 엔드포인트 추가
+7. `ApplyUserPenaltyServiceTest` 작성
+8. `./gradlew spotlessApply` 실행 후 테스트
+
+DB 스키마 변경이 없으므로 마이그레이션 작업은 없다.
diff --git a/src/main/java/team/washer/server/v2/domain/machine/entity/Machine.java b/src/main/java/team/washer/server/v2/domain/machine/entity/Machine.java
index 81bed457..47e548ff 100644
--- a/src/main/java/team/washer/server/v2/domain/machine/entity/Machine.java
+++ b/src/main/java/team/washer/server/v2/domain/machine/entity/Machine.java
@@ -106,6 +106,28 @@ public void markAsAvailable() {
this.availability = MachineAvailability.AVAILABLE;
}
+ /**
+ * 예약 또는 사용 중으로 점유되어 있던 경우에만 기기를 사용 가능 상태로 해제합니다.
+ *
+ *
+ * 고장난 기기는 해제하지 않고 {@code UNAVAILABLE}로 되돌립니다. {@code status}가
+ * {@code MALFUNCTION}이면 {@code availability}도 {@code UNAVAILABLE}이어야 한다는 불변식을
+ * 예약 해제 경로에서 보장하기 위함입니다. 고장 처리 시점에 이미 진행 중이던 예약이 스케줄러에 의해 {@code IN_USE}로 전환되는
+ * 등, 다른 경로에서 어긋난 상태로 들어오더라도 이 지점에서 불변식이 복원됩니다.
+ *
+ *
+ * 고장이 아닌데 {@code UNAVAILABLE}로 차단된 기기는 관리자가 의도적으로 내린 상태이므로 그대로 유지합니다.
+ */
+ public void releaseIfHeld() {
+ if (this.status == MachineStatus.MALFUNCTION) {
+ this.availability = MachineAvailability.UNAVAILABLE;
+ return;
+ }
+ if (this.availability == MachineAvailability.RESERVED || this.availability == MachineAvailability.IN_USE) {
+ this.availability = MachineAvailability.AVAILABLE;
+ }
+ }
+
/**
* 기기 사용 중 상태로 변경합니다.
*/
@@ -120,6 +142,25 @@ public void markAsReserved() {
this.availability = MachineAvailability.RESERVED;
}
+ /**
+ * 기기를 통세척 중 상태로 변경합니다.
+ */
+ public void markAsCleaning() {
+ this.availability = MachineAvailability.CLEANING;
+ }
+
+ /**
+ * 통세척 중인 기기를 정상 상태에 맞게 해제합니다.
+ */
+ public void finishCleaning() {
+ if (this.availability != MachineAvailability.CLEANING) {
+ return;
+ }
+ this.availability = this.status == MachineStatus.NORMAL
+ ? MachineAvailability.AVAILABLE
+ : MachineAvailability.UNAVAILABLE;
+ }
+
/**
* 기기 사용 불가 상태로 변경합니다.
*/
diff --git a/src/main/java/team/washer/server/v2/domain/machine/enums/MachineAvailability.java b/src/main/java/team/washer/server/v2/domain/machine/enums/MachineAvailability.java
index 94412e83..e8b23644 100644
--- a/src/main/java/team/washer/server/v2/domain/machine/enums/MachineAvailability.java
+++ b/src/main/java/team/washer/server/v2/domain/machine/enums/MachineAvailability.java
@@ -6,7 +6,7 @@
@Getter
@AllArgsConstructor
public enum MachineAvailability {
- AVAILABLE("사용 가능"), IN_USE("사용 중"), RESERVED("예약됨"), UNAVAILABLE("사용 불가");
+ AVAILABLE("사용 가능"), IN_USE("사용 중"), RESERVED("예약됨"), CLEANING("통세척 중"), UNAVAILABLE("사용 불가");
private final String description;
}
diff --git a/src/main/java/team/washer/server/v2/domain/machine/repository/MachineRepository.java b/src/main/java/team/washer/server/v2/domain/machine/repository/MachineRepository.java
index 247419be..9aefa788 100644
--- a/src/main/java/team/washer/server/v2/domain/machine/repository/MachineRepository.java
+++ b/src/main/java/team/washer/server/v2/domain/machine/repository/MachineRepository.java
@@ -38,6 +38,12 @@ public interface MachineRepository extends JpaRepository, Machine
List findByType(MachineType type);
+ List findByTypeAndStatusAndAvailability(MachineType type,
+ MachineStatus status,
+ MachineAvailability availability);
+
+ List findByTypeAndAvailability(MachineType type, MachineAvailability availability);
+
List findByFloor(Integer floor);
List findByStatus(MachineStatus status);
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 0d94563f..a389474d 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
@@ -106,7 +106,8 @@ private MachineStatusResDto mapToStatusDto(Machine machine,
/**
* 예약 정보와 실제 기기 작동 상태를 기반으로 가용성을 동적으로 계산한다. 예약 상태를 유일한 source of truth로 사용하되,
- * 예약이 없어도 SmartThings에서 실제 작동 중(무단 사용)이면 IN_USE로 표시해 중복 예약을 차단한다.
+ * 예약이 없어도 SmartThings에서 실제 작동 중(무단 사용)이면 IN_USE로 표시해 중복 예약을 차단한다. 사용 불가와 통세척 중
+ * 상태는 DB에 저장된 관리 상태를 우선한다.
*
*
* 완료 여부를 이 API에서 따로 예측하지 않는다. 완료 확정은 라이프사이클 스케줄러가 디바운스와 가드를 거쳐 DB에 반영하며, 목록은 그
@@ -115,8 +116,9 @@ private MachineStatusResDto mapToStatusDto(Machine machine,
private MachineAvailability computeAvailability(Machine machine,
Reservation reservation,
SmartThingsDeviceStatusResDto deviceStatus) {
- if (machine.getAvailability() == MachineAvailability.UNAVAILABLE) {
- return MachineAvailability.UNAVAILABLE;
+ if (machine.getAvailability() == MachineAvailability.UNAVAILABLE
+ || machine.getAvailability() == MachineAvailability.CLEANING) {
+ return machine.getAvailability();
}
if (reservation == null) {
return isOperating(machine, deviceStatus) ? MachineAvailability.IN_USE : MachineAvailability.AVAILABLE;
diff --git a/src/main/java/team/washer/server/v2/domain/notification/entity/Notification.java b/src/main/java/team/washer/server/v2/domain/notification/entity/Notification.java
index 5d949d5e..045fce95 100644
--- a/src/main/java/team/washer/server/v2/domain/notification/entity/Notification.java
+++ b/src/main/java/team/washer/server/v2/domain/notification/entity/Notification.java
@@ -30,7 +30,7 @@ public class Notification extends BaseEntity {
@NotNull(message = "알림 유형은 필수입니다")
@Enumerated(EnumType.STRING)
- @Column(name = "type", nullable = false, length = 20)
+ @Column(name = "type", nullable = false, length = 50)
private NotificationType type;
@ManyToOne(fetch = FetchType.LAZY)
@@ -231,6 +231,22 @@ public static Notification createBlockExtensionNotification(User user, LocalDate
.isRead(false).build();
}
+ /**
+ * 관리자 패널티 부과 알림을 생성합니다.
+ *
+ * @param user
+ * 알림 수신 사용자
+ * @param reason
+ * 패널티 부과 사유
+ * @return 생성된 관리자 패널티 알림
+ */
+ public static Notification createAdminPenaltyNotification(User user, String reason) {
+ String message = NotificationType.ADMIN_PENALTY_BLOCKED.getMessageTemplate().replace("{reason}", reason);
+
+ return Notification.builder().user(user).type(NotificationType.ADMIN_PENALTY_BLOCKED).message(message)
+ .isRead(false).build();
+ }
+
/**
* 알림을 읽음 상태로 변경합니다.
*/
diff --git a/src/main/java/team/washer/server/v2/domain/notification/enums/NotificationType.java b/src/main/java/team/washer/server/v2/domain/notification/enums/NotificationType.java
index 95c79b1c..948faf2e 100644
--- a/src/main/java/team/washer/server/v2/domain/notification/enums/NotificationType.java
+++ b/src/main/java/team/washer/server/v2/domain/notification/enums/NotificationType.java
@@ -26,7 +26,9 @@ public enum NotificationType {
"예약 차단 연장 알림",
"관리자에 의해 예약 차단 기간이 연장되었습니다. {expiryAt}까지 해당 호실의 예약이 제한됩니다."), FORCE_STOPPED(
"강제 정지 알림",
- "관리자에 의해 {machineName}의 {action} 정지되어 예약이 패널티 없이 취소되었습니다.");
+ "관리자에 의해 {machineName}의 {action} 정지되어 예약이 패널티 없이 취소되었습니다."), ADMIN_PENALTY_BLOCKED(
+ "예약 차단 알림",
+ "관리자에 의해 해당 호실의 예약이 48시간 동안 제한됩니다.\n\n사유: {reason}");
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private static final DateTimeFormatter EXPIRY_FORMATTER = DateTimeFormatter.ofPattern("MM월 dd일 HH시 mm분");
diff --git a/src/main/java/team/washer/server/v2/domain/notification/support/ReservationNotificationSupport.java b/src/main/java/team/washer/server/v2/domain/notification/support/ReservationNotificationSupport.java
index fd9eb237..13095702 100644
--- a/src/main/java/team/washer/server/v2/domain/notification/support/ReservationNotificationSupport.java
+++ b/src/main/java/team/washer/server/v2/domain/notification/support/ReservationNotificationSupport.java
@@ -108,6 +108,15 @@ public void sendBlockExtension(User user, LocalDateTime newExpiryAt) {
persistAndSend(user, notification, "예약 차단 알림");
}
+ /**
+ * 관리자 패널티 부과 알림을 전송한다.
+ */
+ @Transactional
+ public void sendAdminPenalty(User user, String reason) {
+ var notification = Notification.createAdminPenaltyNotification(user, reason);
+ persistAndSend(user, notification, "예약 차단 알림");
+ }
+
private void persistAndSend(final User user, final Notification notification, final String fcmTitle) {
notificationRepository.save(notification);
enforceNotificationLimit(user);
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/controller/AdminReservationController.java b/src/main/java/team/washer/server/v2/domain/reservation/controller/AdminReservationController.java
index 05c81fd8..59e860f3 100644
--- a/src/main/java/team/washer/server/v2/domain/reservation/controller/AdminReservationController.java
+++ b/src/main/java/team/washer/server/v2/domain/reservation/controller/AdminReservationController.java
@@ -15,6 +15,7 @@
import team.themoment.sdk.response.CommonApiResponse;
import team.washer.server.v2.domain.machine.enums.MachineType;
import team.washer.server.v2.domain.reservation.dto.request.AdminCreateReservationReqDto;
+import team.washer.server.v2.domain.reservation.dto.request.ApplyUserPenaltyReqDto;
import team.washer.server.v2.domain.reservation.dto.request.ExtendBlockReqDto;
import team.washer.server.v2.domain.reservation.dto.response.AdminCancellationResDto;
import team.washer.server.v2.domain.reservation.dto.response.AdminMachineHistoryResDto;
@@ -32,6 +33,7 @@
public class AdminReservationController {
private final QueryPenaltyStatusService queryPenaltyStatusService;
+ private final ApplyUserPenaltyService applyUserPenaltyService;
private final ClearUserPenaltyService clearUserPenaltyService;
private final ExtendCancellationBlockService extendCancellationBlockService;
private final QueryAllReservationsService queryAllReservationsService;
@@ -53,6 +55,15 @@ public PenaltyStatusResDto getUserPenaltyStatus(
return queryPenaltyStatusService.execute(userId);
}
+ @PostMapping("/users/{userId}/penalty")
+ @Operation(summary = "사용자 세탁 패널티 부과", description = "특정 사용자의 호실에 48시간 예약 차단을 부과합니다. 5분 패널티 5회 누적과 동일한 제재이며, 관리자와 기숙사자치위원회가 사용할 수 있습니다. 이미 차단 중인 호실은 차단 기간이 48시간으로 갱신됩니다.")
+ public CommonApiResponse applyUserPenalty(@Parameter(description = "사용자 ID") @PathVariable @NotNull Long userId,
+ @Valid @RequestBody ApplyUserPenaltyReqDto reqDto) {
+
+ applyUserPenaltyService.execute(userId, reqDto.reason());
+ return CommonApiResponse.success("세탁 패널티가 부과되었습니다.");
+ }
+
@DeleteMapping("/users/{userId}/penalty")
@Operation(summary = "사용자 패널티 해제", description = "특정 사용자의 패널티를 해제합니다. ADMIN 권한이 필요합니다.")
public CommonApiResponse clearUserPenalty(@Parameter(description = "사용자 ID") @PathVariable @NotNull Long userId) {
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/dto/request/ApplyUserPenaltyReqDto.java b/src/main/java/team/washer/server/v2/domain/reservation/dto/request/ApplyUserPenaltyReqDto.java
new file mode 100644
index 00000000..42cb5b0b
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/reservation/dto/request/ApplyUserPenaltyReqDto.java
@@ -0,0 +1,10 @@
+package team.washer.server.v2.domain.reservation.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+@Schema(description = "세탁 패널티 부과 요청 DTO")
+public record ApplyUserPenaltyReqDto(
+ @NotBlank(message = "부과 사유는 필수입니다") @Size(max = 200, message = "부과 사유는 200자를 초과할 수 없습니다") @Schema(description = "패널티 부과 사유", example = "세탁물 장기 방치로 기기 점유") String reason) {
+}
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 18670491..45522868 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
@@ -1,6 +1,5 @@
package team.washer.server.v2.domain.reservation.repository;
-import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -72,10 +71,6 @@ default Optional findActiveReservationByMachineId(Long machineId) {
List.of(ReservationStatus.RESERVED, ReservationStatus.RUNNING)));
}
- @Query("SELECT r FROM Reservation r WHERE r.status = :status AND r.startTime < :threshold")
- List findExpiredReservedReservations(@Param("status") ReservationStatus status,
- @Param("threshold") LocalDateTime threshold);
-
@Query("SELECT COUNT(r) FROM Reservation r WHERE r.machine = :machine AND r.status IN :statuses")
long countActiveReservationsByMachine(@Param("machine") Machine machine,
@Param("statuses") List statuses);
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/ApplyUserPenaltyService.java b/src/main/java/team/washer/server/v2/domain/reservation/service/ApplyUserPenaltyService.java
new file mode 100644
index 00000000..19e0cf95
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/reservation/service/ApplyUserPenaltyService.java
@@ -0,0 +1,14 @@
+package team.washer.server.v2.domain.reservation.service;
+
+public interface ApplyUserPenaltyService {
+
+ /**
+ * 대상 사용자의 호실에 48시간 세탁 패널티를 부과합니다.
+ *
+ * @param userId
+ * 패널티 부과 대상 사용자 ID
+ * @param reason
+ * 부과 사유
+ */
+ void execute(Long userId, String reason);
+}
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/AdminCancelReservationServiceImpl.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/AdminCancelReservationServiceImpl.java
index 46b640cd..7cdd6172 100644
--- a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/AdminCancelReservationServiceImpl.java
+++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/AdminCancelReservationServiceImpl.java
@@ -30,7 +30,7 @@ public AdminCancellationResDto execute(Long reservationId) {
}
reservation.cancel();
final var machine = reservation.getMachine();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
final var savedReservation = reservationRepository.save(reservation);
return new AdminCancellationResDto(savedReservation.getId(),
savedReservation.getUser().getName(),
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ApplyUserPenaltyServiceImpl.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ApplyUserPenaltyServiceImpl.java
new file mode 100644
index 00000000..b4436171
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ApplyUserPenaltyServiceImpl.java
@@ -0,0 +1,71 @@
+package team.washer.server.v2.domain.reservation.service.impl;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import team.themoment.sdk.exception.ExpectedException;
+import team.washer.server.v2.domain.notification.support.ReservationNotificationSupport;
+import team.washer.server.v2.domain.reservation.service.ApplyUserPenaltyService;
+import team.washer.server.v2.domain.reservation.util.PenaltyRedisUtil;
+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;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ApplyUserPenaltyServiceImpl implements ApplyUserPenaltyService {
+
+ private final UserRepository userRepository;
+ private final PenaltyRedisUtil penaltyRedisUtil;
+ private final ReservationNotificationSupport reservationNotificationSupport;
+ private final CurrentUserProvider currentUserProvider;
+
+ @Override
+ @Transactional
+ public void execute(final Long userId, final String reason) {
+ final var actorId = currentUserProvider.getCurrentUserId();
+
+ // 권한 검사는 SecurityConfig가 담당한다(DORMITORY_COUNCIL, ADMIN 허용).
+ // 자치위·관리자 구분은 감사 로그에만 남기므로 actor는 조회만 한다.
+ final User actor = userRepository.findById(actorId)
+ .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
+
+ if (actorId.equals(userId)) {
+ throw new ExpectedException("자신에게는 패널티를 부과할 수 없습니다.", HttpStatus.BAD_REQUEST);
+ }
+
+ final User target = userRepository.findById(userId)
+ .orElseThrow(() -> new ExpectedException("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND));
+
+ final String roomNumber = target.getRoomNumber();
+ if (roomNumber == null) {
+ throw new ExpectedException("호실 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND);
+ }
+
+ // 관리자에게 거짓 성공 응답이 나가면 제재가 집행되지 않은 채 종료되므로 저장 실패를 예외로 받는다.
+ // isBlocked로 판정하면 이미 차단 중인 호실에서 TTL 갱신 실패를 성공으로 오인한다.
+ try {
+ penaltyRedisUtil.applyBlockOrThrow(roomNumber);
+ } catch (Exception e) {
+ log.error("failed to apply admin penalty roomNumber={} targetId={} actorId={}",
+ roomNumber,
+ userId,
+ actorId,
+ e);
+ throw new ExpectedException("패널티 부과에 실패했습니다. 잠시 후 다시 시도해 주세요.", HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+
+ log.info("admin penalty applied roomNumber={} targetId={} actorId={} actorRole={} reason={}",
+ roomNumber,
+ userId,
+ actorId,
+ actor.getRole(),
+ reason);
+
+ reservationNotificationSupport.sendAdminPenalty(target, reason);
+ }
+}
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/CancelReservationServiceImpl.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/CancelReservationServiceImpl.java
index 5a563d9a..d2be7d71 100644
--- a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/CancelReservationServiceImpl.java
+++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/CancelReservationServiceImpl.java
@@ -68,7 +68,7 @@ public CancellationResDto execute(final Long reservationId) {
final var machine = reservation.getMachine();
reservation.cancel();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
log.info("Cancelled reservation reservationId={} userId={}", reservationId, userId);
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/OverdueReservationProcessor.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/OverdueReservationProcessor.java
index 274e44e0..ff6210fd 100644
--- a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/OverdueReservationProcessor.java
+++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/OverdueReservationProcessor.java
@@ -97,7 +97,7 @@ public OverdueResult processOverdue(Long reservationId, SmartThingsDeviceStatusR
if (startDecision == StartDecision.UNKNOWN) {
if (canCancelUnknownReservation(reservation)) {
reservation.cancel();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
log.warn("reservation timeout cancelled without penalty due to unknown start state reservationId={}",
@@ -109,7 +109,7 @@ public OverdueResult processOverdue(Long reservationId, SmartThingsDeviceStatusR
}
reservation.cancel();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ReservationLifecycleProcessor.java b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ReservationLifecycleProcessor.java
index 8259cb35..bff9e264 100644
--- a/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ReservationLifecycleProcessor.java
+++ b/src/main/java/team/washer/server/v2/domain/reservation/service/impl/ReservationLifecycleProcessor.java
@@ -185,7 +185,7 @@ private void processInterruption(Reservation reservation, Machine machine) {
reservation.cancel();
reservation.clearInterruptionCount();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
@@ -214,7 +214,7 @@ private void processPaused(Reservation reservation, Machine machine) {
reservation.cancel();
reservation.clearPausedAt();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
@@ -276,7 +276,7 @@ private void completeReservation(Reservation reservation,
reservation.clearCompletionCount();
reservation.clearInterruptionCount();
reservation.clearPausedAt();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
reservationRepository.save(reservation);
machineRepository.save(machine);
diff --git a/src/main/java/team/washer/server/v2/domain/reservation/util/PenaltyRedisUtil.java b/src/main/java/team/washer/server/v2/domain/reservation/util/PenaltyRedisUtil.java
index 43d5c2fe..25cd7cec 100644
--- a/src/main/java/team/washer/server/v2/domain/reservation/util/PenaltyRedisUtil.java
+++ b/src/main/java/team/washer/server/v2/domain/reservation/util/PenaltyRedisUtil.java
@@ -231,18 +231,38 @@ public LocalDateTime extendBlock(final String roomNumber, final long additionalD
/**
* 48시간 예약 차단을 호실 단위로 적용합니다.
+ *
+ * 실패해도 예외를 던지지 않습니다. 자동 판정 경로(예약 취소·타임아웃)에서 Redis 장애 때문에 예약 처리 트랜잭션 전체가 실패하는 것을
+ * 막기 위함입니다. 부과 성공 여부를 확인해야 하는 호출자는 {@link #applyBlockOrThrow(String)}을 사용해야
+ * 합니다.
+ *
*/
public void applyBlock(final String roomNumber) {
try {
- final long ttlSeconds = PenaltyConstants.CANCELLATION_WINDOW_HOURS * 3600L;
- cancellationBlockRedisRepository
- .save(CancellationBlockEntity.builder().roomNumber(roomNumber).ttl(ttlSeconds).build());
- log.info("48h block applied roomNumber={}", roomNumber);
+ applyBlockOrThrow(roomNumber);
} catch (Exception e) {
log.error("failed to apply 48h block roomNumber={}", roomNumber, e);
}
}
+ /**
+ * 48시간 예약 차단을 호실 단위로 적용하고, 실패 시 예외를 그대로 전파합니다.
+ *
+ * 관리자 수동 부과처럼 집행 성공 여부를 호출자가 반드시 알아야 하는 경로에서 사용합니다.
+ * {@link #isBlocked(String)}으로 성공을 판정하면 이미 차단 중인 호실에서 TTL 갱신 실패를 성공으로 오인하므로, 저장
+ * 실패는 예외로 알려야 합니다.
+ *
+ *
+ * @param roomNumber
+ * 차단 대상 호실 번호
+ */
+ public void applyBlockOrThrow(final String roomNumber) {
+ final long ttlSeconds = PenaltyConstants.CANCELLATION_WINDOW_HOURS * 3600L;
+ cancellationBlockRedisRepository
+ .save(CancellationBlockEntity.builder().roomNumber(roomNumber).ttl(ttlSeconds).build());
+ log.info("48h block applied roomNumber={}", roomNumber);
+ }
+
/**
* 현재 호실이 48시간 예약 차단 중인지 여부를 반환합니다.
*/
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/dto/request/SmartThingsCommandReqDto.java b/src/main/java/team/washer/server/v2/domain/smartthings/dto/request/SmartThingsCommandReqDto.java
index 2ce2c539..dc4f9e0b 100644
--- a/src/main/java/team/washer/server/v2/domain/smartthings/dto/request/SmartThingsCommandReqDto.java
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/dto/request/SmartThingsCommandReqDto.java
@@ -24,6 +24,19 @@ public static SmartThingsCommandReqDto powerOn() {
return new SmartThingsCommandReqDto(List.of(new Command("main", "switch", "on", List.of())));
}
+ /**
+ * 세탁기의 무세제 통세척 코스를 선택하고 실행한다.
+ *
+ * @param cycle
+ * 기기 모델에 맞는 SmartThings 세탁 코스 코드
+ * @return 무세제 통세척 실행 명령
+ */
+ public static SmartThingsCommandReqDto washerTubClean(String cycle) {
+ return new SmartThingsCommandReqDto(
+ List.of(new Command("main", "samsungce.washerCycle", "setWasherCycle", List.of(cycle)),
+ new Command("main", "washerOperatingState", "setMachineState", List.of("run"))));
+ }
+
/**
* 세탁기를 안전하게 정지시킨다. 전원 차단(switch off)과 달리 사이클을 정상 종료하므로 작동 중 기기에 사용한다. 기기에서 원격
* 제어(Smart Control)가 활성화되어 있어야 명령이 적용된다.
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/scheduler/WasherTubCleanScheduler.java b/src/main/java/team/washer/server/v2/domain/smartthings/scheduler/WasherTubCleanScheduler.java
new file mode 100644
index 00000000..dd4ad25f
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/scheduler/WasherTubCleanScheduler.java
@@ -0,0 +1,43 @@
+package team.washer.server.v2.domain.smartthings.scheduler;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import team.washer.server.v2.domain.smartthings.service.ReleaseFinishedWasherTubCleanService;
+import team.washer.server.v2.domain.smartthings.service.RunWasherTubCleanService;
+
+/**
+ * 매주 금요일 오전 10시에 무세제 통세척을 실행하고 완료된 기기의 점유를 해제한다.
+ */
+@Component
+@ConditionalOnProperty(prefix = "third-party.smartthings.tub-clean", name = "enabled", havingValue = "true")
+@RequiredArgsConstructor
+@Slf4j
+public class WasherTubCleanScheduler {
+
+ private static final long COMPLETION_CHECK_INTERVAL = 60000;
+
+ private final RunWasherTubCleanService runWasherTubCleanService;
+ private final ReleaseFinishedWasherTubCleanService releaseFinishedWasherTubCleanService;
+
+ @Scheduled(cron = "${third-party.smartthings.tub-clean.cron:0 0 10 * * FRI}", zone = "Asia/Seoul")
+ public void runTubClean() {
+ try {
+ runWasherTubCleanService.execute();
+ } catch (Exception e) {
+ log.error("washer tub clean scheduler failed", e);
+ }
+ }
+
+ @Scheduled(fixedDelay = COMPLETION_CHECK_INTERVAL)
+ public void releaseFinishedTubClean() {
+ try {
+ releaseFinishedWasherTubCleanService.execute();
+ } catch (Exception e) {
+ log.error("washer tub clean completion check failed", e);
+ }
+ }
+}
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/service/ReleaseFinishedWasherTubCleanService.java b/src/main/java/team/washer/server/v2/domain/smartthings/service/ReleaseFinishedWasherTubCleanService.java
new file mode 100644
index 00000000..df142564
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/service/ReleaseFinishedWasherTubCleanService.java
@@ -0,0 +1,6 @@
+package team.washer.server.v2.domain.smartthings.service;
+
+public interface ReleaseFinishedWasherTubCleanService {
+
+ void execute();
+}
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/service/RunWasherTubCleanService.java b/src/main/java/team/washer/server/v2/domain/smartthings/service/RunWasherTubCleanService.java
new file mode 100644
index 00000000..17fe6b4a
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/service/RunWasherTubCleanService.java
@@ -0,0 +1,6 @@
+package team.washer.server.v2.domain.smartthings.service;
+
+public interface RunWasherTubCleanService {
+
+ void execute();
+}
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ReleaseFinishedWasherTubCleanServiceImpl.java b/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ReleaseFinishedWasherTubCleanServiceImpl.java
new file mode 100644
index 00000000..c14aeece
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/ReleaseFinishedWasherTubCleanServiceImpl.java
@@ -0,0 +1,74 @@
+package team.washer.server.v2.domain.smartthings.service.impl;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import team.washer.server.v2.domain.machine.entity.Machine;
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+import team.washer.server.v2.domain.machine.enums.MachineType;
+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.enums.MachineOperatingState;
+import team.washer.server.v2.domain.smartthings.service.ReleaseFinishedWasherTubCleanService;
+import team.washer.server.v2.domain.smartthings.support.DeviceStatusQuerySupport;
+import team.washer.server.v2.domain.smartthings.support.WasherTubCleanMachineGuard;
+import team.washer.server.v2.global.util.DateTimeUtil;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ReleaseFinishedWasherTubCleanServiceImpl implements ReleaseFinishedWasherTubCleanService {
+
+ private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
+ ReservationStatus.RUNNING);
+ private static final long COMMAND_START_GRACE_MINUTES = 5;
+
+ private final MachineRepository machineRepository;
+ private final ReservationRepository reservationRepository;
+ private final DeviceStatusQuerySupport deviceStatusQuerySupport;
+ private final WasherTubCleanMachineGuard machineGuard;
+
+ @Override
+ public void execute() {
+ var candidates = findCandidates(DateTimeUtil.nowInKorea());
+ if (candidates.isEmpty()) {
+ return;
+ }
+
+ var statusMap = deviceStatusQuerySupport
+ .queryAllDevicesStatus(candidates.stream().map(Machine::getDeviceId).toList());
+ for (var machine : candidates) {
+ var status = statusMap.get(machine.getDeviceId());
+ if (!isFinished(status)) {
+ continue;
+ }
+ if (machineGuard.releaseIfNoActiveReservation(machine.getId())) {
+ log.info("washer tub clean occupancy released machine={} deviceId={}",
+ machine.getName(),
+ machine.getDeviceId());
+ }
+ }
+ }
+
+ private List findCandidates(LocalDateTime now) {
+ var activeMachineIds = Set.copyOf(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES));
+ var graceThreshold = now.minusMinutes(COMMAND_START_GRACE_MINUTES);
+ return machineRepository.findByTypeAndAvailability(MachineType.WASHER, MachineAvailability.CLEANING).stream()
+ .filter(machine -> !activeMachineIds.contains(machine.getId()))
+ .filter(machine -> machine.getDeviceId() != null && !machine.getDeviceId().isBlank())
+ .filter(machine -> machine.getUpdatedAt() == null || !machine.getUpdatedAt().isAfter(graceThreshold))
+ .toList();
+ }
+
+ private boolean isFinished(SmartThingsDeviceStatusResDto status) {
+ return status != null && status.getOperatingState(true) == MachineOperatingState.STOP
+ && !status.isJobStateActive(true);
+ }
+}
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/RunWasherTubCleanServiceImpl.java b/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/RunWasherTubCleanServiceImpl.java
new file mode 100644
index 00000000..2fcb9f02
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/service/impl/RunWasherTubCleanServiceImpl.java
@@ -0,0 +1,138 @@
+package team.washer.server.v2.domain.smartthings.service.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import team.washer.server.v2.domain.machine.entity.Machine;
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+import team.washer.server.v2.domain.machine.enums.MachineStatus;
+import team.washer.server.v2.domain.machine.enums.MachineType;
+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.request.SmartThingsCommandReqDto;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto;
+import team.washer.server.v2.domain.smartthings.enums.MachineOperatingState;
+import team.washer.server.v2.domain.smartthings.exception.SmartThingsPermissionException;
+import team.washer.server.v2.domain.smartthings.service.RunWasherTubCleanService;
+import team.washer.server.v2.domain.smartthings.service.SendDeviceCommandService;
+import team.washer.server.v2.domain.smartthings.support.DeviceStatusQuerySupport;
+import team.washer.server.v2.domain.smartthings.support.WasherTubCleanMachineGuard;
+import team.washer.server.v2.global.thirdparty.smartthings.config.SmartThingsTubCleanEnvironment;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class RunWasherTubCleanServiceImpl implements RunWasherTubCleanService {
+
+ private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
+ ReservationStatus.RUNNING);
+
+ private final MachineRepository machineRepository;
+ private final ReservationRepository reservationRepository;
+ private final DeviceStatusQuerySupport deviceStatusQuerySupport;
+ private final SendDeviceCommandService sendDeviceCommandService;
+ private final WasherTubCleanMachineGuard machineGuard;
+ private final SmartThingsTubCleanEnvironment tubCleanEnvironment;
+
+ @Override
+ public void execute() {
+ if (!tubCleanEnvironment.hasCycle()) {
+ log.error("washer tub clean skipped because cycle is not configured");
+ return;
+ }
+
+ var candidates = findCandidates();
+ if (candidates.isEmpty()) {
+ log.info("washer tub clean skipped because no washer is available");
+ return;
+ }
+
+ var statusMap = deviceStatusQuerySupport
+ .queryAllDevicesStatus(candidates.stream().map(Machine::getDeviceId).toList());
+ var started = new ArrayList();
+ var skipped = new ArrayList();
+ var failed = new ArrayList();
+
+ for (var machine : candidates) {
+ try {
+ var status = statusMap.get(machine.getDeviceId());
+ if (!canStart(machine, status)) {
+ skipped.add(machine.getName());
+ continue;
+ }
+
+ var target = machineGuard.occupyIfAvailable(machine.getId());
+ if (target.isEmpty()) {
+ skipped.add(machine.getName());
+ continue;
+ }
+
+ sendCommand(target.get());
+ started.add(target.get().machineName());
+ } catch (SmartThingsPermissionException e) {
+ log.warn("washer tub clean stopped because SmartThings permission is denied machine={} reason={}",
+ machine.getName(),
+ e.getMessage());
+ break;
+ } catch (Exception e) {
+ failed.add(machine.getName());
+ log.error("washer tub clean failed machine={} reason={}", machine.getName(), e.getMessage(), e);
+ }
+ }
+
+ log.info("washer tub clean batch completed started={} skipped={} failed={}", started, skipped, failed);
+ }
+
+ private List findCandidates() {
+ var activeMachineIds = Set.copyOf(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES));
+ return machineRepository
+ .findByTypeAndStatusAndAvailability(MachineType.WASHER,
+ MachineStatus.NORMAL,
+ MachineAvailability.AVAILABLE)
+ .stream().filter(machine -> !activeMachineIds.contains(machine.getId()))
+ .filter(machine -> machine.getDeviceId() != null && !machine.getDeviceId().isBlank()).toList();
+ }
+
+ private boolean canStart(Machine machine, SmartThingsDeviceStatusResDto status) {
+ if (status == null) {
+ log.warn("washer tub clean skipped because device status is unknown machine={} deviceId={}",
+ machine.getName(),
+ machine.getDeviceId());
+ return false;
+ }
+ if (!status.isRemoteControlEnabled()) {
+ log.info("washer tub clean skipped because remote control is disabled machine={} deviceId={}",
+ machine.getName(),
+ machine.getDeviceId());
+ return false;
+ }
+ if (status.isSwitchOff() || status.getOperatingState(true) != MachineOperatingState.STOP
+ || status.isJobStateActive(true)) {
+ log.info(
+ "washer tub clean skipped because washer is not idle machine={} deviceId={} machineState={} jobState={}",
+ machine.getName(),
+ machine.getDeviceId(),
+ status.getWasherOperatingState(),
+ status.getWasherJobState());
+ return false;
+ }
+ return true;
+ }
+
+ private void sendCommand(WasherTubCleanMachineGuard.TubCleanTarget target) {
+ try {
+ sendDeviceCommandService.execute(target.deviceId(),
+ SmartThingsCommandReqDto.washerTubClean(tubCleanEnvironment.cycle()));
+ log.info("washer tub clean command sent machine={} deviceId={}", target.machineName(), target.deviceId());
+ } catch (Exception e) {
+ machineGuard.releaseIfNoActiveReservation(target.machineId());
+ throw e;
+ }
+ }
+}
diff --git a/src/main/java/team/washer/server/v2/domain/smartthings/support/WasherTubCleanMachineGuard.java b/src/main/java/team/washer/server/v2/domain/smartthings/support/WasherTubCleanMachineGuard.java
new file mode 100644
index 00000000..23505eb8
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/domain/smartthings/support/WasherTubCleanMachineGuard.java
@@ -0,0 +1,75 @@
+package team.washer.server.v2.domain.smartthings.support;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import team.washer.server.v2.domain.machine.entity.Machine;
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+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;
+
+/**
+ * 무세제 통세척 실행과 예약 생성이 충돌하지 않도록 기기 점유를 관리한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class WasherTubCleanMachineGuard {
+
+ private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
+ ReservationStatus.RUNNING);
+
+ private final MachineRepository machineRepository;
+ private final ReservationRepository reservationRepository;
+
+ /**
+ * 기기를 잠근 뒤 실행 조건을 다시 확인하고 통세척 실행 중으로 점유한다.
+ *
+ * @param machineId
+ * 점유할 세탁기 ID
+ * @return 점유된 세탁기의 식별 정보. 조건이 맞지 않으면 빈 값
+ */
+ @Transactional
+ public Optional occupyIfAvailable(Long machineId) {
+ var machine = machineRepository.findByIdForUpdate(machineId).orElse(null);
+ if (machine == null || !machine.isWasher() || !machine.isAvailable() || hasActiveReservation(machine)) {
+ return Optional.empty();
+ }
+
+ machine.markAsCleaning();
+ return Optional.of(new TubCleanTarget(machine.getId(), machine.getName(), machine.getDeviceId()));
+ }
+
+ /**
+ * 활성 예약이 없는 통세척 점유 상태를 해제한다.
+ *
+ * @param machineId
+ * 해제할 세탁기 ID
+ * @return 해제 여부
+ */
+ @Transactional
+ public boolean releaseIfNoActiveReservation(Long machineId) {
+ var machine = machineRepository.findByIdForUpdate(machineId).orElse(null);
+ if (machine == null || !machine.isWasher() || hasActiveReservation(machine)
+ || machine.getAvailability() != MachineAvailability.CLEANING) {
+ return false;
+ }
+
+ machine.finishCleaning();
+ return true;
+ }
+
+ private boolean hasActiveReservation(Machine machine) {
+ return reservationRepository.countActiveReservationsByMachine(machine, ACTIVE_STATUSES) > 0;
+ }
+
+ /**
+ * 트랜잭션 밖에서 SmartThings 명령에 사용할 세탁기 식별 정보.
+ */
+ public record TubCleanTarget(Long machineId, String machineName, String deviceId) {
+ }
+}
diff --git a/src/main/java/team/washer/server/v2/domain/user/service/impl/WithdrawUserServiceImpl.java b/src/main/java/team/washer/server/v2/domain/user/service/impl/WithdrawUserServiceImpl.java
index c934325c..52b6b5bd 100644
--- a/src/main/java/team/washer/server/v2/domain/user/service/impl/WithdrawUserServiceImpl.java
+++ b/src/main/java/team/washer/server/v2/domain/user/service/impl/WithdrawUserServiceImpl.java
@@ -48,7 +48,7 @@ public void execute() {
for (final var reservation : activeReservations) {
final var machine = reservation.getMachine();
reservation.cancel();
- machine.markAsAvailable();
+ machine.releaseIfHeld();
machinesToUpdate.add(machine);
}
machineRepository.saveAll(machinesToUpdate);
diff --git a/src/main/java/team/washer/server/v2/global/thirdparty/smartthings/config/SmartThingsTubCleanEnvironment.java b/src/main/java/team/washer/server/v2/global/thirdparty/smartthings/config/SmartThingsTubCleanEnvironment.java
new file mode 100644
index 00000000..f11e0da3
--- /dev/null
+++ b/src/main/java/team/washer/server/v2/global/thirdparty/smartthings/config/SmartThingsTubCleanEnvironment.java
@@ -0,0 +1,24 @@
+package team.washer.server.v2.global.thirdparty.smartthings.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.util.StringUtils;
+
+/**
+ * SmartThings 무세제 통세척 자동 실행 설정.
+ *
+ * @param enabled
+ * 자동 실행 활성화 여부
+ * @param cycle
+ * 기기 모델에 맞는 SmartThings 세탁 코스 코드
+ */
+@ConfigurationProperties(prefix = "third-party.smartthings.tub-clean")
+public record SmartThingsTubCleanEnvironment(boolean enabled, String cycle) {
+
+ public SmartThingsTubCleanEnvironment {
+ cycle = cycle == null ? null : cycle.trim();
+ }
+
+ public boolean hasCycle() {
+ return StringUtils.hasText(cycle);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 30e378b7..4725377e 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -50,6 +50,10 @@ third-party:
client-secret: ${SMARTTHINGS_CLIENT_SECRET:your-client-secret}
operation-schedule:
enabled: false
+ tub-clean:
+ enabled: ${SMARTTHINGS_TUB_CLEAN_ENABLED:false}
+ cron: ${SMARTTHINGS_TUB_CLEAN_CRON:0 0 10 * * FRI}
+ cycle: ${SMARTTHINGS_TUB_CLEAN_CYCLE:}
datagsm:
client-secret: ${THIRD_PARTY_DATAGSM_CLIENT_SECRET:your-client-secret}
client-id: ${THIRD_PARTY_DATAGSM_CLIENT_ID:your-client-id}
diff --git a/src/test/java/team/washer/server/v2/domain/machine/entity/MachineTest.java b/src/test/java/team/washer/server/v2/domain/machine/entity/MachineTest.java
new file mode 100644
index 00000000..f4bf08f5
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/machine/entity/MachineTest.java
@@ -0,0 +1,196 @@
+package team.washer.server.v2.domain.machine.entity;
+
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+import team.washer.server.v2.domain.machine.enums.MachineStatus;
+import team.washer.server.v2.domain.machine.enums.MachineType;
+import team.washer.server.v2.domain.machine.enums.Position;
+
+@DisplayName("Machine 클래스의")
+class MachineTest {
+
+ private Machine createMachine() {
+ return Machine.builder().name("W-2F-L1").type(MachineType.WASHER).deviceId("device-1").floor(2)
+ .position(Position.LEFT).number(1).status(MachineStatus.NORMAL)
+ .availability(MachineAvailability.AVAILABLE).build();
+ }
+
+ @Nested
+ @DisplayName("통세척 상태 변경 메서드는")
+ class Describe_cleaning_state {
+
+ @Test
+ @DisplayName("통세척을 시작하면 통세척 중 상태로 변경해야 한다")
+ void it_marks_machine_as_cleaning() {
+ var machine = createMachine();
+
+ machine.markAsCleaning();
+
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.CLEANING);
+ }
+
+ @Test
+ @DisplayName("정상 기기의 통세척이 끝나면 사용 가능 상태로 변경해야 한다")
+ void it_finishes_cleaning_on_normal_machine() {
+ var machine = createMachine();
+ machine.markAsCleaning();
+
+ machine.finishCleaning();
+
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.AVAILABLE);
+ }
+
+ @Test
+ @DisplayName("예약 해제는 통세척 중 상태를 변경하지 않아야 한다")
+ void it_keeps_cleaning_state_when_reservation_is_released() {
+ var machine = createMachine();
+ machine.markAsCleaning();
+
+ machine.releaseIfHeld();
+
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.CLEANING);
+ }
+
+ @Test
+ @DisplayName("고장난 기기의 통세척 점유를 해제하면 사용 불가 상태를 유지해야 한다")
+ void it_keeps_malfunction_machine_unavailable_after_cleaning() {
+ var machine = createMachine();
+ machine.markAsCleaning();
+ ReflectionTestUtils.setField(machine, "status", MachineStatus.MALFUNCTION);
+
+ machine.finishCleaning();
+
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ }
+ }
+
+ @Nested
+ @DisplayName("releaseIfHeld 메서드는")
+ class Describe_releaseIfHeld {
+
+ @Test
+ @DisplayName("예약됨 상태의 기기를 사용 가능 상태로 해제해야 한다")
+ void it_releases_reserved_machine() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsReserved();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.AVAILABLE);
+ }
+
+ @Test
+ @DisplayName("사용 중 상태의 기기를 사용 가능 상태로 해제해야 한다")
+ void it_releases_in_use_machine() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsInUse();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.AVAILABLE);
+ }
+
+ @Test
+ @DisplayName("고장 처리된 기기의 상태를 그대로 유지해야 한다")
+ void it_keeps_malfunction_machine_unavailable() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsMalfunction();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getStatus()).isEqualTo(MachineStatus.MALFUNCTION);
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ }
+
+ @Test
+ @DisplayName("고장난 기기가 예약됨 상태로 어긋나 있어도 사용 불가로 되돌려야 한다")
+ void it_restores_unavailable_when_malfunction_machine_is_reserved() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsMalfunction();
+ machine.markAsReserved();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getStatus()).isEqualTo(MachineStatus.MALFUNCTION);
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ }
+
+ @Test
+ @DisplayName("고장난 기기가 사용 중 상태로 어긋나 있어도 사용 불가로 되돌려야 한다")
+ void it_restores_unavailable_when_malfunction_machine_is_in_use() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsMalfunction();
+ machine.markAsInUse();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getStatus()).isEqualTo(MachineStatus.MALFUNCTION);
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ }
+
+ @Test
+ @DisplayName("고장난 기기가 사용 가능 상태로 어긋나 있어도 사용 불가로 되돌려야 한다")
+ void it_restores_unavailable_when_malfunction_machine_is_available() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsMalfunction();
+ machine.markAsAvailable();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getStatus()).isEqualTo(MachineStatus.MALFUNCTION);
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ }
+
+ @Test
+ @DisplayName("사용 불가로 처리된 정상 기기의 상태를 그대로 유지해야 한다")
+ void it_keeps_unavailable_normal_machine_unavailable() {
+ // Given
+ final var machine = createMachine();
+ machine.markAsUnavailable();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getStatus()).isEqualTo(MachineStatus.NORMAL);
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ }
+
+ @Test
+ @DisplayName("이미 사용 가능한 기기의 상태를 그대로 유지해야 한다")
+ void it_keeps_available_machine_available() {
+ // Given
+ final var machine = createMachine();
+
+ // When
+ machine.releaseIfHeld();
+
+ // Then
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.AVAILABLE);
+ }
+ }
+}
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 4dcc799c..5e305105 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
@@ -348,5 +348,19 @@ void computeAvailability_ShouldReturnUnavailable_WhenMachineIsUnavailable() {
// Then
assertThat(result.getFirst().availability()).isEqualTo(MachineAvailability.UNAVAILABLE);
}
+
+ @Test
+ @DisplayName("기기가 통세척 중이면 예약과 무관하게 CLEANING을 반환한다")
+ void computeAvailability_ShouldReturnCleaning_WhenMachineIsCleaning() {
+ // Given
+ when(reservation.getUser()).thenReturn(user);
+ givenMachineWithReservation(buildMachine(MachineAvailability.CLEANING), reservation);
+
+ // When
+ var result = queryAllMachinesStatusService.execute(USER_ID, true);
+
+ // Then
+ assertThat(result.getFirst().availability()).isEqualTo(MachineAvailability.CLEANING);
+ }
}
}
diff --git a/src/test/java/team/washer/server/v2/domain/reservation/dto/request/ApplyUserPenaltyReqDtoTest.java b/src/test/java/team/washer/server/v2/domain/reservation/dto/request/ApplyUserPenaltyReqDtoTest.java
new file mode 100644
index 00000000..3f32af8d
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/reservation/dto/request/ApplyUserPenaltyReqDtoTest.java
@@ -0,0 +1,66 @@
+package team.washer.server.v2.domain.reservation.dto.request;
+
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import jakarta.validation.Validation;
+
+@DisplayName("ApplyUserPenaltyReqDto의")
+class ApplyUserPenaltyReqDtoTest {
+
+ private static final int REASON_MAX_LENGTH = 200;
+
+ @Test
+ @DisplayName("부과 사유는 200자까지 허용한다")
+ void reason_allows_max_length() {
+ try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
+ var validator = validatorFactory.getValidator();
+ var request = new ApplyUserPenaltyReqDto("가".repeat(REASON_MAX_LENGTH));
+
+ var violations = validator.validate(request);
+
+ assertThat(violations).isEmpty();
+ }
+ }
+
+ @Test
+ @DisplayName("부과 사유가 200자를 초과하면 검증에 실패한다")
+ void reason_rejects_over_max_length() {
+ try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
+ var validator = validatorFactory.getValidator();
+ var request = new ApplyUserPenaltyReqDto("가".repeat(REASON_MAX_LENGTH + 1));
+
+ var violations = validator.validate(request);
+
+ assertThat(violations).extracting(violation -> violation.getMessage()).contains("부과 사유는 200자를 초과할 수 없습니다");
+ }
+ }
+
+ @Test
+ @DisplayName("부과 사유가 공백이면 검증에 실패한다")
+ void reason_rejects_blank() {
+ try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
+ var validator = validatorFactory.getValidator();
+ var request = new ApplyUserPenaltyReqDto(" ");
+
+ var violations = validator.validate(request);
+
+ assertThat(violations).extracting(violation -> violation.getMessage()).contains("부과 사유는 필수입니다");
+ }
+ }
+
+ @Test
+ @DisplayName("부과 사유가 null이면 검증에 실패한다")
+ void reason_rejects_null() {
+ try (var validatorFactory = Validation.buildDefaultValidatorFactory()) {
+ var validator = validatorFactory.getValidator();
+ var request = new ApplyUserPenaltyReqDto(null);
+
+ var violations = validator.validate(request);
+
+ assertThat(violations).extracting(violation -> violation.getMessage()).contains("부과 사유는 필수입니다");
+ }
+ }
+}
diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/ApplyUserPenaltyServiceTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/ApplyUserPenaltyServiceTest.java
new file mode 100644
index 00000000..a221d014
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/reservation/service/ApplyUserPenaltyServiceTest.java
@@ -0,0 +1,252 @@
+package team.washer.server.v2.domain.reservation.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.BDDMockito.*;
+
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.RedisConnectionFailureException;
+import org.springframework.http.HttpStatus;
+
+import team.themoment.sdk.exception.ExpectedException;
+import team.washer.server.v2.domain.notification.support.ReservationNotificationSupport;
+import team.washer.server.v2.domain.reservation.service.impl.ApplyUserPenaltyServiceImpl;
+import team.washer.server.v2.domain.reservation.util.PenaltyRedisUtil;
+import team.washer.server.v2.domain.user.entity.User;
+import team.washer.server.v2.domain.user.enums.UserRole;
+import team.washer.server.v2.domain.user.repository.UserRepository;
+import team.washer.server.v2.global.security.provider.CurrentUserProvider;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ApplyUserPenaltyServiceImpl 클래스의")
+class ApplyUserPenaltyServiceTest {
+
+ private static final Long ACTOR_ID = 1L;
+ private static final Long TARGET_ID = 2L;
+ private static final String TARGET_ROOM = "302";
+ private static final String REASON = "세탁물 장기 방치로 기기 점유";
+
+ @InjectMocks
+ private ApplyUserPenaltyServiceImpl applyUserPenaltyService;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private PenaltyRedisUtil penaltyRedisUtil;
+
+ @Mock
+ private ReservationNotificationSupport reservationNotificationSupport;
+
+ @Mock
+ private CurrentUserProvider currentUserProvider;
+
+ private User createAdmin() {
+ return User.builder().name("관리자").studentId("20210001").roomNumber("301").grade(3).floor(3).role(UserRole.ADMIN)
+ .build();
+ }
+
+ private User createDormitoryCouncil() {
+ return User.builder().name("자치위원").studentId("20210003").roomNumber("303").grade(3).floor(3)
+ .role(UserRole.DORMITORY_COUNCIL).build();
+ }
+
+ private User createTarget() {
+ return User.builder().name("대상사용자").studentId("20210002").roomNumber(TARGET_ROOM).grade(3).floor(3)
+ .role(UserRole.USER).build();
+ }
+
+ private User createTargetWithoutRoom() {
+ return User.builder().name("호실없음").studentId("20210004").grade(3).floor(3).role(UserRole.USER).build();
+ }
+
+ @Nested
+ @DisplayName("execute 메서드는")
+ class Describe_execute {
+
+ @Nested
+ @DisplayName("관리자가 패널티를 부과할 때")
+ class Context_with_admin {
+
+ @Test
+ @DisplayName("호실에 48시간 차단을 적용하고 알림을 발송해야 한다")
+ void it_applies_block_and_sends_notification() {
+ // Given
+ final var target = createTarget();
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createAdmin()));
+ given(userRepository.findById(TARGET_ID)).willReturn(Optional.of(target));
+
+ // When
+ applyUserPenaltyService.execute(TARGET_ID, REASON);
+
+ // Then
+ then(penaltyRedisUtil).should(times(1)).applyBlockOrThrow(TARGET_ROOM);
+ then(reservationNotificationSupport).should(times(1)).sendAdminPenalty(target, REASON);
+ }
+ }
+
+ @Nested
+ @DisplayName("기숙사자치위원회가 패널티를 부과할 때")
+ class Context_with_dormitory_council {
+
+ @Test
+ @DisplayName("관리자와 동일하게 패널티가 부과되어야 한다")
+ void it_applies_block_for_dormitory_council() {
+ // Given
+ final var target = createTarget();
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createDormitoryCouncil()));
+ given(userRepository.findById(TARGET_ID)).willReturn(Optional.of(target));
+
+ // When
+ applyUserPenaltyService.execute(TARGET_ID, REASON);
+
+ // Then
+ then(penaltyRedisUtil).should(times(1)).applyBlockOrThrow(TARGET_ROOM);
+ then(reservationNotificationSupport).should(times(1)).sendAdminPenalty(target, REASON);
+ }
+ }
+
+ @Nested
+ @DisplayName("자기 자신에게 패널티를 부과할 때")
+ class Context_with_self_target {
+
+ @Test
+ @DisplayName("ExpectedException이 발생하고 BAD_REQUEST 상태를 반환해야 한다")
+ void it_throws_bad_request_exception() {
+ // Given
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createAdmin()));
+
+ // When & Then
+ assertThatThrownBy(() -> applyUserPenaltyService.execute(ACTOR_ID, REASON))
+ .isInstanceOf(ExpectedException.class).hasMessage("자신에게는 패널티를 부과할 수 없습니다.")
+ .satisfies(e -> assertThat(((ExpectedException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST));
+
+ then(penaltyRedisUtil).shouldHaveNoInteractions();
+ then(reservationNotificationSupport).shouldHaveNoInteractions();
+ }
+ }
+
+ @Nested
+ @DisplayName("존재하지 않는 부과자 ID로 요청할 때")
+ class Context_with_nonexistent_actor {
+
+ @Test
+ @DisplayName("ExpectedException이 발생하고 NOT_FOUND 상태를 반환해야 한다")
+ void it_throws_not_found_exception() {
+ // Given
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.empty());
+
+ // When & Then
+ assertThatThrownBy(() -> applyUserPenaltyService.execute(TARGET_ID, REASON))
+ .isInstanceOf(ExpectedException.class).hasMessage("사용자를 찾을 수 없습니다.")
+ .satisfies(e -> assertThat(((ExpectedException) e).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+
+ then(penaltyRedisUtil).shouldHaveNoInteractions();
+ then(reservationNotificationSupport).shouldHaveNoInteractions();
+ }
+ }
+
+ @Nested
+ @DisplayName("존재하지 않는 대상 사용자에게 부과할 때")
+ class Context_with_nonexistent_target {
+
+ @Test
+ @DisplayName("ExpectedException이 발생하고 NOT_FOUND 상태를 반환해야 한다")
+ void it_throws_not_found_exception() {
+ // Given
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createAdmin()));
+ given(userRepository.findById(TARGET_ID)).willReturn(Optional.empty());
+
+ // When & Then
+ assertThatThrownBy(() -> applyUserPenaltyService.execute(TARGET_ID, REASON))
+ .isInstanceOf(ExpectedException.class).hasMessage("사용자를 찾을 수 없습니다.")
+ .satisfies(e -> assertThat(((ExpectedException) e).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+
+ then(penaltyRedisUtil).shouldHaveNoInteractions();
+ then(reservationNotificationSupport).shouldHaveNoInteractions();
+ }
+ }
+
+ @Nested
+ @DisplayName("대상 사용자의 호실 정보가 없을 때")
+ class Context_without_room_number {
+
+ @Test
+ @DisplayName("ExpectedException이 발생하고 NOT_FOUND 상태를 반환해야 한다")
+ void it_throws_not_found_exception() {
+ // Given
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createAdmin()));
+ given(userRepository.findById(TARGET_ID)).willReturn(Optional.of(createTargetWithoutRoom()));
+
+ // When & Then
+ assertThatThrownBy(() -> applyUserPenaltyService.execute(TARGET_ID, REASON))
+ .isInstanceOf(ExpectedException.class).hasMessage("호실 정보를 찾을 수 없습니다.")
+ .satisfies(e -> assertThat(((ExpectedException) e).getStatusCode())
+ .isEqualTo(HttpStatus.NOT_FOUND));
+
+ then(penaltyRedisUtil).shouldHaveNoInteractions();
+ then(reservationNotificationSupport).shouldHaveNoInteractions();
+ }
+ }
+
+ @Nested
+ @DisplayName("Redis 장애로 차단 적용에 실패했을 때")
+ class Context_with_redis_failure {
+
+ @Test
+ @DisplayName("ExpectedException이 발생하고 알림을 발송하지 않아야 한다")
+ void it_throws_and_skips_notification() {
+ // Given
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createAdmin()));
+ given(userRepository.findById(TARGET_ID)).willReturn(Optional.of(createTarget()));
+ willThrow(new RedisConnectionFailureException("connection refused")).given(penaltyRedisUtil)
+ .applyBlockOrThrow(TARGET_ROOM);
+
+ // When & Then
+ assertThatThrownBy(() -> applyUserPenaltyService.execute(TARGET_ID, REASON))
+ .isInstanceOf(ExpectedException.class).hasMessage("패널티 부과에 실패했습니다. 잠시 후 다시 시도해 주세요.")
+ .satisfies(e -> assertThat(((ExpectedException) e).getStatusCode())
+ .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR));
+
+ then(penaltyRedisUtil).should(times(1)).applyBlockOrThrow(TARGET_ROOM);
+ then(reservationNotificationSupport).shouldHaveNoInteractions();
+ }
+
+ @Test
+ @DisplayName("이미 차단 중인 호실이어도 갱신 실패를 성공으로 판정하지 않아야 한다")
+ void it_does_not_rely_on_is_blocked() {
+ // Given
+ given(currentUserProvider.getCurrentUserId()).willReturn(ACTOR_ID);
+ given(userRepository.findById(ACTOR_ID)).willReturn(Optional.of(createAdmin()));
+ given(userRepository.findById(TARGET_ID)).willReturn(Optional.of(createTarget()));
+ willThrow(new RedisConnectionFailureException("connection refused")).given(penaltyRedisUtil)
+ .applyBlockOrThrow(TARGET_ROOM);
+
+ // When & Then
+ assertThatThrownBy(() -> applyUserPenaltyService.execute(TARGET_ID, REASON))
+ .isInstanceOf(ExpectedException.class);
+
+ // 이미 차단 중인 호실에서 isBlocked는 true를 반환하므로 성공 판정 근거로 쓰면 안 된다
+ then(penaltyRedisUtil).should(never()).isBlocked(anyString());
+ then(reservationNotificationSupport).shouldHaveNoInteractions();
+ }
+ }
+ }
+}
diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/OverdueReservationProcessorTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/OverdueReservationProcessorTest.java
index b66f5f14..51133aba 100644
--- a/src/test/java/team/washer/server/v2/domain/reservation/service/OverdueReservationProcessorTest.java
+++ b/src/test/java/team/washer/server/v2/domain/reservation/service/OverdueReservationProcessorTest.java
@@ -227,7 +227,7 @@ void shouldCancelWithoutPenalty_WhenProxyReservation() {
// Then
assertThat(result).isEqualTo(OverdueResult.CANCELLED_WITHOUT_PENALTY);
verify(reservation, times(1)).cancel();
- verify(machine, times(1)).markAsAvailable();
+ verify(machine, times(1)).releaseIfHeld();
verify(reservationRepository, times(1)).save(reservation);
verifyNoInteractions(penaltyRedisUtil);
verifyNoInteractions(reservationNotificationSupport);
@@ -327,7 +327,7 @@ void shouldCancelWithoutPenalty_WhenStartDecisionUnknownAfterGrace() {
// Then
assertThat(result).isEqualTo(OverdueResult.CANCELLED_WITHOUT_PENALTY);
verify(reservation, times(1)).cancel();
- verify(machine, times(1)).markAsAvailable();
+ verify(machine, times(1)).releaseIfHeld();
verify(reservationRepository, times(1)).save(reservation);
verify(machineRepository, times(1)).save(machine);
verify(penaltyRedisUtil, never()).applyCooldown(any(), any());
diff --git a/src/test/java/team/washer/server/v2/domain/reservation/service/ReservationLifecycleProcessorTest.java b/src/test/java/team/washer/server/v2/domain/reservation/service/ReservationLifecycleProcessorTest.java
index 8daec989..3f17a319 100644
--- a/src/test/java/team/washer/server/v2/domain/reservation/service/ReservationLifecycleProcessorTest.java
+++ b/src/test/java/team/washer/server/v2/domain/reservation/service/ReservationLifecycleProcessorTest.java
@@ -258,7 +258,7 @@ void shouldCompleteReservation_WhenCompletionConfirmed() {
verify(reservation, times(1)).clearCompletionCount();
verify(reservation, times(1)).clearInterruptionCount();
verify(reservation, times(1)).clearPausedAt();
- verify(machine, times(1)).markAsAvailable();
+ verify(machine, times(1)).releaseIfHeld();
verify(reservationRepository, times(1)).save(reservation);
verify(machineRepository, times(1)).save(machine);
verify(reservationNotificationSupport, times(1)).sendCompletion(user, machine);
@@ -280,7 +280,7 @@ void shouldOnlyIncrementCount_WhenCompletionBelowThreshold() {
// Then
verify(reservation, times(1)).incrementCompletionCount();
verify(reservation, never()).complete();
- verify(machine, never()).markAsAvailable();
+ verify(machine, never()).releaseIfHeld();
verify(reservationRepository, times(1)).save(reservation);
verify(machineRepository, never()).save(machine);
verify(reservationNotificationSupport, never()).sendCompletion(any(), any());
@@ -396,7 +396,7 @@ void shouldCancelWithoutPenaltyAndNotify_WhenInterruptionConfirmed() {
verify(reservation, times(1)).incrementInterruptionCount();
verify(reservation, times(1)).cancel();
verify(reservation, times(1)).clearInterruptionCount();
- verify(machine, times(1)).markAsAvailable();
+ verify(machine, times(1)).releaseIfHeld();
verify(reservationRepository, times(1)).save(reservation);
verify(machineRepository, times(1)).save(machine);
verify(reservationNotificationSupport, times(1)).sendInterruption(user, machine);
@@ -451,7 +451,7 @@ void shouldCancelWithoutPenaltyAndNotify_WhenPausedTooLong() {
// Then
verify(reservation, times(1)).cancel();
verify(reservation, times(1)).clearPausedAt();
- verify(machine, times(1)).markAsAvailable();
+ verify(machine, times(1)).releaseIfHeld();
verify(reservationRepository, times(1)).save(reservation);
verify(machineRepository, times(1)).save(machine);
verify(reservationNotificationSupport, times(1)).sendPauseTimeout(user, machine);
diff --git a/src/test/java/team/washer/server/v2/domain/smartthings/scheduler/WasherTubCleanSchedulerTest.java b/src/test/java/team/washer/server/v2/domain/smartthings/scheduler/WasherTubCleanSchedulerTest.java
new file mode 100644
index 00000000..e560e20f
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/smartthings/scheduler/WasherTubCleanSchedulerTest.java
@@ -0,0 +1,66 @@
+package team.washer.server.v2.domain.smartthings.scheduler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.times;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import team.washer.server.v2.domain.smartthings.service.ReleaseFinishedWasherTubCleanService;
+import team.washer.server.v2.domain.smartthings.service.RunWasherTubCleanService;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("WasherTubCleanScheduler 클래스의")
+class WasherTubCleanSchedulerTest {
+
+ @InjectMocks
+ private WasherTubCleanScheduler scheduler;
+
+ @Mock
+ private RunWasherTubCleanService runWasherTubCleanService;
+
+ @Mock
+ private ReleaseFinishedWasherTubCleanService releaseFinishedWasherTubCleanService;
+
+ @Test
+ @DisplayName("통세척 예약 시각에 실행 서비스를 호출해야 한다")
+ void it_runs_tub_clean_service() {
+ scheduler.runTubClean();
+
+ then(runWasherTubCleanService).should(times(1)).execute();
+ }
+
+ @Test
+ @DisplayName("통세척 실행 시각은 매주 금요일 오전 10시와 서울 시간대여야 한다")
+ void it_schedules_friday_at_ten_in_seoul() throws NoSuchMethodException {
+ var annotation = WasherTubCleanScheduler.class.getMethod("runTubClean").getAnnotation(Scheduled.class);
+
+ assertThat(annotation.cron()).isEqualTo("${third-party.smartthings.tub-clean.cron:0 0 10 * * FRI}");
+ assertThat(annotation.zone()).isEqualTo("Asia/Seoul");
+ }
+
+ @Test
+ @DisplayName("한 번의 실행 실패가 다음 스케줄 실행을 막지 않아야 한다")
+ void it_handles_run_failure() {
+ willThrow(new RuntimeException("실행 실패")).given(runWasherTubCleanService).execute();
+
+ scheduler.runTubClean();
+
+ then(runWasherTubCleanService).should(times(1)).execute();
+ }
+
+ @Test
+ @DisplayName("완료된 통세척 점유 해제 서비스를 호출해야 한다")
+ void it_releases_finished_tub_clean() {
+ scheduler.releaseFinishedTubClean();
+
+ then(releaseFinishedWasherTubCleanService).should(times(1)).execute();
+ }
+}
diff --git a/src/test/java/team/washer/server/v2/domain/smartthings/service/ReleaseFinishedWasherTubCleanServiceTest.java b/src/test/java/team/washer/server/v2/domain/smartthings/service/ReleaseFinishedWasherTubCleanServiceTest.java
new file mode 100644
index 00000000..5c0e4d29
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/smartthings/service/ReleaseFinishedWasherTubCleanServiceTest.java
@@ -0,0 +1,110 @@
+package team.washer.server.v2.domain.smartthings.service;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import team.washer.server.v2.domain.machine.entity.Machine;
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+import team.washer.server.v2.domain.machine.enums.MachineStatus;
+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.dto.response.SmartThingsDeviceStatusResDto.AttributeState;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.ComponentStatus;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.WasherOperatingState;
+import team.washer.server.v2.domain.smartthings.service.impl.ReleaseFinishedWasherTubCleanServiceImpl;
+import team.washer.server.v2.domain.smartthings.support.DeviceStatusQuerySupport;
+import team.washer.server.v2.domain.smartthings.support.WasherTubCleanMachineGuard;
+import team.washer.server.v2.global.util.DateTimeUtil;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ReleaseFinishedWasherTubCleanServiceImpl 클래스의")
+class ReleaseFinishedWasherTubCleanServiceTest {
+
+ private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
+ ReservationStatus.RUNNING);
+
+ @InjectMocks
+ private ReleaseFinishedWasherTubCleanServiceImpl releaseService;
+
+ @Mock
+ private MachineRepository machineRepository;
+
+ @Mock
+ private ReservationRepository reservationRepository;
+
+ @Mock
+ private DeviceStatusQuerySupport deviceStatusQuerySupport;
+
+ @Mock
+ private WasherTubCleanMachineGuard machineGuard;
+
+ private Machine createMachine(LocalDateTime updatedAt) {
+ var machine = Machine.builder().name("W-2F-L1").type(MachineType.WASHER).deviceId("device-1").floor(2)
+ .position(Position.LEFT).number(1).status(MachineStatus.NORMAL)
+ .availability(MachineAvailability.CLEANING).build();
+ ReflectionTestUtils.setField(machine, "id", 1L);
+ ReflectionTestUtils.setField(machine, "updatedAt", updatedAt);
+ return machine;
+ }
+
+ private SmartThingsDeviceStatusResDto stoppedStatus() {
+ var washerState = new WasherOperatingState(new AttributeState("stop", null, null),
+ new AttributeState("finish", null, null),
+ null);
+ return new SmartThingsDeviceStatusResDto(Map.of("main", new ComponentStatus(washerState, null, null, null)));
+ }
+
+ @Nested
+ @DisplayName("execute 메서드는")
+ class Describe_execute {
+
+ @Test
+ @DisplayName("완료된 통세척 세탁기의 점유를 해제해야 한다")
+ void it_releases_finished_tub_clean() {
+ var machine = createMachine(DateTimeUtil.nowInKorea().minusMinutes(10));
+ given(machineRepository.findByTypeAndAvailability(MachineType.WASHER, MachineAvailability.CLEANING))
+ .willReturn(List.of(machine));
+ given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of());
+ given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1")))
+ .willReturn(Map.of("device-1", stoppedStatus()));
+ given(machineGuard.releaseIfNoActiveReservation(1L)).willReturn(true);
+
+ releaseService.execute();
+
+ then(machineGuard).should(times(1)).releaseIfNoActiveReservation(1L);
+ }
+
+ @Test
+ @DisplayName("명령 전송 직후에는 정지 상태가 조회되어도 점유를 해제하지 않아야 한다")
+ void it_keeps_occupancy_during_command_start_grace_period() {
+ var machine = createMachine(DateTimeUtil.nowInKorea());
+ given(machineRepository.findByTypeAndAvailability(MachineType.WASHER, MachineAvailability.CLEANING))
+ .willReturn(List.of(machine));
+ given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of());
+
+ releaseService.execute();
+
+ then(deviceStatusQuerySupport).shouldHaveNoInteractions();
+ then(machineGuard).should(never()).releaseIfNoActiveReservation(1L);
+ }
+ }
+}
diff --git a/src/test/java/team/washer/server/v2/domain/smartthings/service/RunWasherTubCleanServiceTest.java b/src/test/java/team/washer/server/v2/domain/smartthings/service/RunWasherTubCleanServiceTest.java
new file mode 100644
index 00000000..09d21edd
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/smartthings/service/RunWasherTubCleanServiceTest.java
@@ -0,0 +1,188 @@
+package team.washer.server.v2.domain.smartthings.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.BDDMockito.willThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import team.washer.server.v2.domain.machine.entity.Machine;
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+import team.washer.server.v2.domain.machine.enums.MachineStatus;
+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.request.SmartThingsCommandReqDto;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.AttributeState;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.ComponentStatus;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.RemoteControlStatus;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.SwitchCapability;
+import team.washer.server.v2.domain.smartthings.dto.response.SmartThingsDeviceStatusResDto.WasherOperatingState;
+import team.washer.server.v2.domain.smartthings.service.impl.RunWasherTubCleanServiceImpl;
+import team.washer.server.v2.domain.smartthings.support.DeviceStatusQuerySupport;
+import team.washer.server.v2.domain.smartthings.support.WasherTubCleanMachineGuard;
+import team.washer.server.v2.domain.smartthings.support.WasherTubCleanMachineGuard.TubCleanTarget;
+import team.washer.server.v2.global.thirdparty.smartthings.config.SmartThingsTubCleanEnvironment;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RunWasherTubCleanServiceImpl 클래스의")
+class RunWasherTubCleanServiceTest {
+
+ private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
+ ReservationStatus.RUNNING);
+
+ @InjectMocks
+ private RunWasherTubCleanServiceImpl runWasherTubCleanService;
+
+ @Mock
+ private MachineRepository machineRepository;
+
+ @Mock
+ private ReservationRepository reservationRepository;
+
+ @Mock
+ private DeviceStatusQuerySupport deviceStatusQuerySupport;
+
+ @Mock
+ private SendDeviceCommandService sendDeviceCommandService;
+
+ @Mock
+ private WasherTubCleanMachineGuard machineGuard;
+
+ @Mock
+ private SmartThingsTubCleanEnvironment tubCleanEnvironment;
+
+ private Machine createMachine() {
+ var machine = Machine.builder().name("W-2F-L1").type(MachineType.WASHER).deviceId("device-1").floor(2)
+ .position(Position.LEFT).number(1).status(MachineStatus.NORMAL)
+ .availability(MachineAvailability.AVAILABLE).build();
+ ReflectionTestUtils.setField(machine, "id", 1L);
+ return machine;
+ }
+
+ private SmartThingsDeviceStatusResDto idleStatus(boolean remoteEnabled) {
+ var washerState = new WasherOperatingState(new AttributeState("stop", null, null),
+ new AttributeState("none", null, null),
+ null);
+ var remoteControl = new RemoteControlStatus(new AttributeState(String.valueOf(remoteEnabled), null, null));
+ var switchCapability = new SwitchCapability(new AttributeState("on", null, null));
+ return new SmartThingsDeviceStatusResDto(
+ Map.of("main", new ComponentStatus(washerState, null, switchCapability, remoteControl)));
+ }
+
+ @Nested
+ @DisplayName("execute 메서드는")
+ class Describe_execute {
+
+ @Test
+ @DisplayName("통세척 코스가 설정되지 않으면 기기를 조회하지 않아야 한다")
+ void it_skips_when_cycle_is_not_configured() {
+ given(tubCleanEnvironment.hasCycle()).willReturn(false);
+
+ runWasherTubCleanService.execute();
+
+ then(machineRepository).shouldHaveNoInteractions();
+ then(sendDeviceCommandService).shouldHaveNoInteractions();
+ }
+
+ @Test
+ @DisplayName("유휴 세탁기를 점유하고 통세척 코스와 실행 명령을 전송해야 한다")
+ void it_starts_tub_clean_on_idle_washer() {
+ var machine = createMachine();
+ given(tubCleanEnvironment.hasCycle()).willReturn(true);
+ given(tubCleanEnvironment.cycle()).willReturn("Course_82");
+ given(machineRepository.findByTypeAndStatusAndAvailability(MachineType.WASHER,
+ MachineStatus.NORMAL,
+ MachineAvailability.AVAILABLE)).willReturn(List.of(machine));
+ given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of());
+ given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1")))
+ .willReturn(Map.of("device-1", idleStatus(true)));
+ given(machineGuard.occupyIfAvailable(1L))
+ .willReturn(Optional.of(new TubCleanTarget(1L, "W-2F-L1", "device-1")));
+
+ runWasherTubCleanService.execute();
+
+ var commandCaptor = ArgumentCaptor.forClass(SmartThingsCommandReqDto.class);
+ then(sendDeviceCommandService).should(times(1)).execute(eq("device-1"), commandCaptor.capture());
+ assertThat(commandCaptor.getValue().commands()).hasSize(2);
+ assertThat(commandCaptor.getValue().commands().get(0).capability()).isEqualTo("samsungce.washerCycle");
+ assertThat(commandCaptor.getValue().commands().get(0).arguments()).containsExactly("Course_82");
+ assertThat(commandCaptor.getValue().commands().get(1).command()).isEqualTo("setMachineState");
+ }
+
+ @Test
+ @DisplayName("활성 예약이 있는 세탁기는 상태 조회와 실행에서 제외해야 한다")
+ void it_skips_reserved_washer() {
+ var machine = createMachine();
+ given(tubCleanEnvironment.hasCycle()).willReturn(true);
+ given(machineRepository.findByTypeAndStatusAndAvailability(MachineType.WASHER,
+ MachineStatus.NORMAL,
+ MachineAvailability.AVAILABLE)).willReturn(List.of(machine));
+ given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of(1L));
+
+ runWasherTubCleanService.execute();
+
+ then(deviceStatusQuerySupport).shouldHaveNoInteractions();
+ then(sendDeviceCommandService).shouldHaveNoInteractions();
+ }
+
+ @Test
+ @DisplayName("원격 제어가 꺼진 세탁기는 실행하지 않아야 한다")
+ void it_skips_washer_with_remote_control_disabled() {
+ var machine = createMachine();
+ given(tubCleanEnvironment.hasCycle()).willReturn(true);
+ given(machineRepository.findByTypeAndStatusAndAvailability(MachineType.WASHER,
+ MachineStatus.NORMAL,
+ MachineAvailability.AVAILABLE)).willReturn(List.of(machine));
+ given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of());
+ given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1")))
+ .willReturn(Map.of("device-1", idleStatus(false)));
+
+ runWasherTubCleanService.execute();
+
+ then(machineGuard).should(never()).occupyIfAvailable(any());
+ then(sendDeviceCommandService).shouldHaveNoInteractions();
+ }
+
+ @Test
+ @DisplayName("명령 전송에 실패하면 세탁기 점유를 해제해야 한다")
+ void it_releases_occupancy_when_command_fails() {
+ var machine = createMachine();
+ given(tubCleanEnvironment.hasCycle()).willReturn(true);
+ given(tubCleanEnvironment.cycle()).willReturn("Course_82");
+ given(machineRepository.findByTypeAndStatusAndAvailability(MachineType.WASHER,
+ MachineStatus.NORMAL,
+ MachineAvailability.AVAILABLE)).willReturn(List.of(machine));
+ given(reservationRepository.findMachineIdsByStatusIn(ACTIVE_STATUSES)).willReturn(List.of());
+ given(deviceStatusQuerySupport.queryAllDevicesStatus(List.of("device-1")))
+ .willReturn(Map.of("device-1", idleStatus(true)));
+ given(machineGuard.occupyIfAvailable(1L))
+ .willReturn(Optional.of(new TubCleanTarget(1L, "W-2F-L1", "device-1")));
+ willThrow(new RuntimeException("명령 전송 실패")).given(sendDeviceCommandService).execute(eq("device-1"), any());
+
+ runWasherTubCleanService.execute();
+
+ then(machineGuard).should(times(1)).releaseIfNoActiveReservation(1L);
+ }
+ }
+}
diff --git a/src/test/java/team/washer/server/v2/domain/smartthings/support/WasherTubCleanMachineGuardTest.java b/src/test/java/team/washer/server/v2/domain/smartthings/support/WasherTubCleanMachineGuardTest.java
new file mode 100644
index 00000000..f2659c43
--- /dev/null
+++ b/src/test/java/team/washer/server/v2/domain/smartthings/support/WasherTubCleanMachineGuardTest.java
@@ -0,0 +1,101 @@
+package team.washer.server.v2.domain.smartthings.support;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import team.washer.server.v2.domain.machine.entity.Machine;
+import team.washer.server.v2.domain.machine.enums.MachineAvailability;
+import team.washer.server.v2.domain.machine.enums.MachineStatus;
+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;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("WasherTubCleanMachineGuard 클래스의")
+class WasherTubCleanMachineGuardTest {
+
+ private static final List ACTIVE_STATUSES = List.of(ReservationStatus.RESERVED,
+ ReservationStatus.RUNNING);
+
+ @InjectMocks
+ private WasherTubCleanMachineGuard machineGuard;
+
+ @Mock
+ private MachineRepository machineRepository;
+
+ @Mock
+ private ReservationRepository reservationRepository;
+
+ private Machine createMachine() {
+ var machine = Machine.builder().name("W-2F-L1").type(MachineType.WASHER).deviceId("device-1").floor(2)
+ .position(Position.LEFT).number(1).status(MachineStatus.NORMAL)
+ .availability(MachineAvailability.AVAILABLE).build();
+ ReflectionTestUtils.setField(machine, "id", 1L);
+ return machine;
+ }
+
+ @Nested
+ @DisplayName("occupyIfAvailable 메서드는")
+ class Describe_occupyIfAvailable {
+
+ @Test
+ @DisplayName("사용 가능한 세탁기를 통세척 중 상태로 점유해야 한다")
+ void it_occupies_available_washer() {
+ var machine = createMachine();
+ given(machineRepository.findByIdForUpdate(1L)).willReturn(Optional.of(machine));
+ given(reservationRepository.countActiveReservationsByMachine(machine, ACTIVE_STATUSES)).willReturn(0L);
+
+ var result = machineGuard.occupyIfAvailable(1L);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().deviceId()).isEqualTo("device-1");
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.CLEANING);
+ }
+
+ @Test
+ @DisplayName("활성 예약이 생긴 세탁기는 점유하지 않아야 한다")
+ void it_skips_washer_with_active_reservation() {
+ var machine = createMachine();
+ given(machineRepository.findByIdForUpdate(1L)).willReturn(Optional.of(machine));
+ given(reservationRepository.countActiveReservationsByMachine(machine, ACTIVE_STATUSES)).willReturn(1L);
+
+ var result = machineGuard.occupyIfAvailable(1L);
+
+ assertThat(result).isEmpty();
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.AVAILABLE);
+ }
+ }
+
+ @Nested
+ @DisplayName("releaseIfNoActiveReservation 메서드는")
+ class Describe_releaseIfNoActiveReservation {
+
+ @Test
+ @DisplayName("활성 예약이 없는 통세척 중 세탁기의 점유를 해제해야 한다")
+ void it_releases_unreserved_washer() {
+ var machine = createMachine();
+ machine.markAsCleaning();
+ given(machineRepository.findByIdForUpdate(1L)).willReturn(Optional.of(machine));
+ given(reservationRepository.countActiveReservationsByMachine(machine, ACTIVE_STATUSES)).willReturn(0L);
+
+ var released = machineGuard.releaseIfNoActiveReservation(1L);
+
+ assertThat(released).isTrue();
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.AVAILABLE);
+ }
+ }
+}
diff --git a/src/test/java/team/washer/server/v2/domain/user/service/WithdrawUserServiceTest.java b/src/test/java/team/washer/server/v2/domain/user/service/WithdrawUserServiceTest.java
index 0423edb6..8bf2ebef 100644
--- a/src/test/java/team/washer/server/v2/domain/user/service/WithdrawUserServiceTest.java
+++ b/src/test/java/team/washer/server/v2/domain/user/service/WithdrawUserServiceTest.java
@@ -143,6 +143,36 @@ void it_cancels_reservation_and_deletes_user() {
}
}
+ @Nested
+ @DisplayName("고장 처리된 기기를 예약 중이던 사용자가 탈퇴하면")
+ class Context_with_reservation_on_malfunction_machine {
+
+ @Test
+ @DisplayName("예약만 취소하고 기기는 사용 불가 상태로 유지해야 한다")
+ void it_cancels_reservation_and_keeps_machine_unavailable() {
+ // Given
+ Long userId = 1L;
+ User user = createUser();
+ Machine machine = createMachine();
+ machine.markAsMalfunction();
+ Reservation reservation = createReservation(ReservationStatus.RESERVED, user, machine);
+
+ given(currentUserProvider.getCurrentUserId()).willReturn(userId);
+ given(userRepository.findById(userId)).willReturn(Optional.of(user));
+ given(reservationRepository.findByUserAndStatusInForUpdate(user, ACTIVE_STATUSES))
+ .willReturn(List.of(reservation));
+
+ // When
+ withdrawUserService.execute();
+
+ // Then
+ assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED);
+ assertThat(machine.getStatus()).isEqualTo(MachineStatus.MALFUNCTION);
+ assertThat(machine.getAvailability()).isEqualTo(MachineAvailability.UNAVAILABLE);
+ then(userRepository).should(times(1)).delete(user);
+ }
+ }
+
@Nested
@DisplayName("RUNNING 상태의 예약이 있는 사용자가 탈퇴하면")
class Context_with_running_reservation {