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 {