From bcb9c7148a3c4adb1a47542aacb82c349bd8bebe Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 12 Aug 2025 20:08:29 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(Reservation):=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 30 +++++ .../WaitingPermitLuaRepository.java | 108 ++++++++++++++++++ .../WaitingPermitLuaRepositoryTest.java | 104 +++++++++++++++++ .../nowait/common/exception/ErrorMessage.java | 3 + .../UserWaitingLimitExceededException.java | 10 ++ 5 files changed, 255 insertions(+) create mode 100644 nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingPermitLuaRepository.java create mode 100644 nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java create mode 100644 nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java index e332ac26..9b4c5f4d 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java @@ -29,7 +29,10 @@ import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException; import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException; import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; +import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException; @@ -244,6 +247,33 @@ public ErrorResponse handleStorePaymentNotFoundException(StorePaymentNotFoundExc return new ErrorResponse(e.getMessage(), STORE_PAYMENT_NOT_FOUND.getCode()); } + @ResponseStatus(CONFLICT) + @ExceptionHandler(UserWaitingLimitExceededException.class) + public ErrorResponse handleUserWaitingLimitExceededException( + UserWaitingLimitExceededException e, WebRequest request) { + alarm(e, request); + log.error("handleUserWaitingLimitExceededException", e); + return new ErrorResponse(e.getMessage(), USER_WAITING_LIMIT_EXCEEDED.getCode()); + } + + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(ReservationNumberIssueFailException.class) + public ErrorResponse handleReservationNumberIssueFailException( + ReservationNumberIssueFailException e, WebRequest request) { + alarm(e, request); + log.error("handleReservationNumberIssueFailException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_NUMBER_ISSUE_FAIL.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(ReservationAddUnauthorizedException.class) + public ErrorResponse handleReservationAddUnauthorizedException( + ReservationAddUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("handleReservationAddUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_ADD_UNAUTHORIZED.getCode()); + } + // 공통 에러 Map 생성 private static Map getErrors(MethodArgumentNotValidException e) { return e.getBindingResult() diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingPermitLuaRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingPermitLuaRepository.java new file mode 100644 index 00000000..8804919f --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingPermitLuaRepository.java @@ -0,0 +1,108 @@ +package com.nowait.applicationuser.reservation.repository; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.redis.connection.ReturnType; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WaitingPermitLuaRepository { + + private final StringRedisTemplate redis; + + private static final String ACQUIRE_SCRIPT = + "redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]);" + + "local holding = redis.call('ZCARD', KEYS[1]);" + + "local active = redis.call('SCARD', KEYS[2]);" + + "if (holding + active) >= tonumber(ARGV[3]) then return 0 end;" + + "redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[2]), ARGV[4]);" + + "return 1;"; + + private static final String FINALIZE_SCRIPT = + "redis.call('ZREM', KEYS[1], ARGV[1]);" + + "redis.call('SADD', KEYS[2], ARGV[2]);" + + "return 1;"; + + public boolean acquireLease(String userId, String token, long nowMs, long leaseMs, int limit, Duration ttlTo3am) { + final String hk = RedisKeyUtils.buildUserHoldingKey(userId); // u:{uid}:holding + final String ak = RedisKeyUtils.buildUserActiveKey(userId); // u:{uid}:active + + Long ok = redis.execute((RedisCallback) conn -> { + Object res = conn.eval( + ACQUIRE_SCRIPT.getBytes(StandardCharsets.UTF_8), + ReturnType.INTEGER, + 2, + raw(hk), raw(ak), + raw(Long.toString(nowMs)), + raw(Long.toString(leaseMs)), + raw(Integer.toString(limit)), + raw(token) + ); + // TTL 정렬(스크립트 밖에서) + conn.pExpire(raw(hk), ttlTo3am.toMillis()); + conn.pExpire(raw(ak), ttlTo3am.toMillis()); + return (Long) res; + }); + return ok != null && ok == 1L; + } + + public void finalizeActive(String userId, String token, String storeId, String reservationId, Duration ttlTo3am) { + final String hk = RedisKeyUtils.buildUserHoldingKey(userId); + final String ak = RedisKeyUtils.buildUserActiveKey(userId); + final String member = storeId + ":" + reservationId; + + redis.execute((RedisCallback) conn -> { + conn.eval( + FINALIZE_SCRIPT.getBytes(StandardCharsets.UTF_8), + ReturnType.INTEGER, + 2, + raw(hk), raw(ak), + raw(token), raw(member) + ); + conn.pExpire(raw(ak), ttlTo3am.toMillis()); + return null; + }); + } + + public void releaseLease(String userId, String token) { + final String hk = RedisKeyUtils.buildUserHoldingKey(userId); + redis.execute((RedisCallback) conn -> { + conn.zRem(raw(hk), raw(token)); + return null; + }); + } + + public Set getActiveMembers(String userId) { + final String ak = RedisKeyUtils.buildUserActiveKey(userId); + return redis.execute((RedisCallback>) conn -> { + Set raw = conn.sMembers(raw(ak)); + if (raw == null || raw.isEmpty()) return Collections.emptySet(); + Set out = new HashSet<>(raw.size()); + for (byte[] b : raw) out.add(string(b)); + return out; + }); + } + + public void removeActiveMember(String userId, String storeId, String reservationId) { + final String ak = RedisKeyUtils.buildUserActiveKey(userId); + final String member = storeId + ":" + reservationId; + redis.execute((RedisCallback) conn -> { + conn.sRem(raw(ak), raw(member)); + return null; + }); + } + + private byte[] raw(String s) { return redis.getStringSerializer().serialize(s); } + private String string(byte[] b) { return redis.getStringSerializer().deserialize(b); } +} diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java new file mode 100644 index 00000000..6f22a617 --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java @@ -0,0 +1,104 @@ +package com.nowait.applicationuser.reservation.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.util.Set; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.nowait.applicationuser.reservation.repository.WaitingPermitLuaRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +@Testcontainers +public class WaitingPermitLuaRepositoryTest { + + @Container + static GenericContainer redis = new GenericContainer<>("redis:7.2-alpine").withExposedPorts(6379); + + static StringRedisTemplate template; + static WaitingPermitLuaRepository permitRepo; + + final String userId = "U1"; + final Duration ttlTo3am = Duration.ofHours(6); // 테스트용 + final long leaseMs = 30_000; + + @BeforeAll + static void setupAll() { + var factory = new LettuceConnectionFactory(redis.getHost(), redis.getMappedPort(6379)); + factory.afterPropertiesSet(); + template = new StringRedisTemplate(factory); + template.afterPropertiesSet(); + permitRepo = new WaitingPermitLuaRepository(template); + } + + @BeforeEach + void flush() { + template.getConnectionFactory().getConnection().serverCommands().flushAll(); + } + + @Test + void acquireLease_underLimit_succeedsUpTo3() { + String hk = RedisKeyUtils.buildUserHoldingKey(userId); + String ak = RedisKeyUtils.buildUserActiveKey(userId); + long now = System.currentTimeMillis(); + + // 1,2,3번째 임대 성공 + assertThat(permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am)).isTrue(); + assertThat(permitRepo.acquireLease(userId, "t2", now, leaseMs, 3, ttlTo3am)).isTrue(); + assertThat(permitRepo.acquireLease(userId, "t3", now, leaseMs, 3, ttlTo3am)).isTrue(); + + // 4번째는 실패 + assertThat(permitRepo.acquireLease(userId, "t4", now, leaseMs, 3, ttlTo3am)).isFalse(); + + // holding 3개 확인 + Long holding = template.opsForZSet().zCard(hk); + Long active = template.opsForSet().size(ak); + assertThat(holding).isEqualTo(3L); + assertThat(active).isEqualTo(0L); + } + + @Test + void finalize_movesFromHoldingToActive_totalCountMaintained() { + long now = System.currentTimeMillis(); + // 두 개 임대 획득 + assertThat(permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am)).isTrue(); + assertThat(permitRepo.acquireLease(userId, "t2", now, leaseMs, 3, ttlTo3am)).isTrue(); + + // t1 확정 + permitRepo.finalizeActive(userId, "t1", "10", "10-20250101-0001", ttlTo3am); + + String hk = RedisKeyUtils.buildUserHoldingKey(userId); + String ak = RedisKeyUtils.buildUserActiveKey(userId); + + assertThat(template.opsForZSet().zCard(hk)).isEqualTo(1L); // t2만 holding + assertThat(template.opsForSet().size(ak)).isEqualTo(1L); // active 1 + assertThat(template.opsForSet().isMember(ak, "10:10-20250101-0001")).isTrue(); + } + + @Test + void releaseLease_removesFromHolding() { + long now = System.currentTimeMillis(); + assertThat(permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am)).isTrue(); + permitRepo.releaseLease(userId, "t1"); + + String hk = RedisKeyUtils.buildUserHoldingKey(userId); + assertThat(template.opsForZSet().zCard(hk)).isEqualTo(0L); + } + + @Test + void getActiveMembers_returnsSetMembers() { + long now = System.currentTimeMillis(); + permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am); + permitRepo.finalizeActive(userId, "t1", "77", "77-20250101-0001", ttlTo3am); + Set ms = permitRepo.getActiveMembers(userId); + assertThat(ms).containsExactly("77:77-20250101-0001"); + } +} diff --git a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java index b8a416da..be45f308 100644 --- a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java +++ b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java @@ -28,6 +28,9 @@ public enum ErrorMessage { RESERVATION_VIEW_UNAUTHORIZED("예약 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "reservation002"), RESERVATION_UPDATE_UNAUTHORIZED("예약 수정 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "reservation003"), DUPLICATE_RESERVATION("이미 대기 중인 예약이 존재합니다.", "reservation004"), + USER_WAITING_LIMIT_EXCEEDED("유저당 웨이팅 가능 개수(3개)를 초과했습니다.", "reservation005"), + RESERVATION_NUMBER_ISSUE_FAIL("예약 번호 발급에 실패했습니다.", "reservation006"), + RESERVATION_ADD_UNAUTHORIZED("MANAGER는 예약 대기를 할 수 없습니다.", "reservation007"), // bookmark DUPLICATE_BOOKMARK("이미 북마크한 주점입니다.", "bookmark001"), diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java new file mode 100644 index 00000000..dccdff12 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UserWaitingLimitExceededException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class UserWaitingLimitExceededException extends RuntimeException { + public UserWaitingLimitExceededException() { + super(ErrorMessage.USER_WAITING_LIMIT_EXCEEDED.getMessage()); + } +} + From e88fcb5b531071b9bc3deb4c9c7ca75cc55d0a82 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 12 Aug 2025 20:08:39 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(Reservation):=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A0=88=EB=94=94=EC=8A=A4=20=ED=82=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domaincoreredis/common/util/RedisKeyUtils.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java index ca37768d..f40d5690 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -22,6 +22,15 @@ public class RedisKeyUtils { private static final String WAITING_PARTYSIZE_KEY_PREFIX = "waiting:party:"; private static final String WAITING_STATUS_KEY_PREFIX = "waiting:status:"; + // Waiting User keys + public static String buildUserHoldingKey(String userId) { + return "waiting:user:{" + userId + "}:holding"; // ZSET(member=token, score=expireEpochMs) + } + public static String buildUserActiveKey(String userId) { + return "waiting:user:{" + userId + "}:active"; // SET(member="storeId:reservationId") + } + + private RedisKeyUtils() { throw new UnsupportedOperationException("유틸리티 서비스는 인스턴스화 할 수 없습니다."); } From 56da2119d32edb985373cd8e61b70b6c685a82b3 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 12 Aug 2025 20:08:45 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(Reservation):=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ReservationAddUnauthorizedException.java | 10 ++++++++++ .../exception/ReservationNumberIssueFailException.java | 10 ++++++++++ 2 files changed, 20 insertions(+) create mode 100644 nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java create mode 100644 nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java new file mode 100644 index 00000000..2f506793 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationAddUnauthorizedException extends RuntimeException { + public ReservationAddUnauthorizedException() { + super(ErrorMessage.RESERVATION_ADD_UNAUTHORIZED.getMessage()); + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java new file mode 100644 index 00000000..30cf81c2 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationNumberIssueFailException extends RuntimeException { + public ReservationNumberIssueFailException() { + super(ErrorMessage.RESERVATION_NUMBER_ISSUE_FAIL.getMessage()); + } +} + From 3250d1d6dd6f4944a769bbab875d5600a9da9d18 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 12 Aug 2025 20:09:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(Reservation):=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EB=A1=9C=EC=A7=81=20=EC=9D=BC=EB=B6=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReservationService.java | 153 ++++++++++++++---- 1 file changed, 121 insertions(+), 32 deletions(-) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java index 4b3c3edd..69c5956a 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java @@ -1,5 +1,6 @@ package com.nowait.applicationuser.reservation.service; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -18,10 +19,10 @@ import org.springframework.transaction.annotation.Transactional; import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto; -import com.nowait.applicationuser.reservation.dto.MyWaitingStoreInfo; import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto; import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.repository.WaitingPermitLuaRepository; import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; import com.nowait.common.enums.ReservationStatus; import com.nowait.common.enums.Role; @@ -29,6 +30,9 @@ import com.nowait.domaincorerdb.department.repository.DepartmentRepository; import com.nowait.domaincorerdb.reservation.entity.Reservation; import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; +import com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; import com.nowait.domaincorerdb.store.entity.ImageType; import com.nowait.domaincorerdb.store.entity.Store; @@ -55,41 +59,116 @@ public class ReservationService { private final WaitingUserRedisRepository waitingUserRedisRepository; private final DepartmentRepository departmentRepository; private final StoreImageRepository storeImageRepository; + private final WaitingPermitLuaRepository waitingPermitLuaRepository; private final RedisTemplate redisTemplate; - - public WaitingResponseDto registerWaiting( - Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto - ) { - // Store 유효성 검증 추가 - Store store = storeRepository.findById(storeId) - .orElseThrow(StoreNotFoundException::new); + private static final int USER_LIMIT = 3; + private static final long LEASE_MS = 30_000; // 20~60초 권장 + + /** + * 웨이팅 등록 기존 로직 + */ + // public WaitingResponseDto registerWaiting( + // Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto + // ) { + // // Store 유효성 검증 추가 + // Store store = storeRepository.findById(storeId) + // .orElseThrow(StoreNotFoundException::new); + // if (Boolean.FALSE.equals(store.getIsActive())) + // throw new StoreWaitingDisabledException(); + // + // // User Role 검증 추가 + // User user = userRepository.findById(customOAuth2User.getUserId()) + // .orElseThrow(UserNotFoundException::new); + // if (user.getRole() == Role.MANAGER) { + // throw new IllegalArgumentException("Manager cannot register waiting"); + // } + // + // String userId = customOAuth2User.getUserId().toString(); + // long timestamp = System.currentTimeMillis(); + // + // // 예약 신청 유저 큐(queue)에 추가 + // String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(), + // timestamp); + // if (reservationId == null) { + // throw new IllegalStateException("예약 번호 발급 실패"); + // } + // + // // 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환 + // Long rank = waitingUserRedisRepository.getRank(storeId, userId); + // return WaitingResponseDto.builder() + // .reservationNumber(reservationId) + // .rank(rank == null ? -1 : rank.intValue() + 1) + // .partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize()) + // .build(); + // } + public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User principal, + ReservationCreateRequestDto dto) { + + // 0) 스토어/유저 검증 복구 + Store store = storeRepository.findById(storeId).orElseThrow(StoreNotFoundException::new); if (Boolean.FALSE.equals(store.getIsActive())) throw new StoreWaitingDisabledException(); - // User Role 검증 추가 - User user = userRepository.findById(customOAuth2User.getUserId()) - .orElseThrow(UserNotFoundException::new); - if (user.getRole() == Role.MANAGER) { - throw new IllegalArgumentException("Manager cannot register waiting"); + User user = userRepository.findById(principal.getUserId()).orElseThrow(UserNotFoundException::new); + if (user.getRole() == Role.MANAGER) + throw new ReservationAddUnauthorizedException(); + + // (기존 유효성 검사 동일) + String userId = user.getId().toString(); + Duration ttlTo3am = waitingUserRedisRepository.calculateTTLUntilNext03AM(); + + // 1) 이미 해당 store에 대기 중이면 임대 없이 현재 상태 반환 (중복 요청 허용) + if (Boolean.TRUE.equals(waitingUserRedisRepository.isUserWaiting(storeId, userId))) { + Long rank = waitingUserRedisRepository.getRank(storeId, userId); + Integer ps = waitingUserRedisRepository.getPartySize(storeId, userId); + String reservationId = waitingUserRedisRepository.getReservationId(storeId, userId); + return WaitingResponseDto.builder() + .reservationNumber(reservationId) + .rank(rank == null ? -1 : rank.intValue() + 1) + .partySize(ps == null ? 0 : ps) + .build(); } - String userId = customOAuth2User.getUserId().toString(); - long timestamp = System.currentTimeMillis(); - - // 예약 신청 유저 큐(queue)에 추가 - String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(), - timestamp); - if (reservationId == null) { - throw new IllegalStateException("예약 번호 발급 실패"); + // 1) 임대 획득 + String token = java.util.UUID.randomUUID().toString(); + int attempts = 0; + while (true) { + boolean ok = waitingPermitLuaRepository.acquireLease(userId, token, System.currentTimeMillis(), LEASE_MS, + USER_LIMIT, ttlTo3am); + if (ok) + break; + if (++attempts >= 3) + throw new UserWaitingLimitExceededException(); + try { + Thread.sleep((long)(5 * Math.pow(3, attempts - 1))); + } catch (InterruptedException ignored) { + } } - // 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환 - Long rank = waitingUserRedisRepository.getRank(storeId, userId); - return WaitingResponseDto.builder() - .reservationNumber(reservationId) - .rank(rank == null ? -1 : rank.intValue() + 1) - .partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize()) - .build(); + String reservationId = null; + try { + // 2) 스토어 큐 등록(기존 메서드 그대로) + long ts = System.currentTimeMillis(); + reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, dto.getPartySize(), ts); + if (reservationId == null) + throw new ReservationNumberIssueFailException(); + + // 3) 확정(holding→active) + waitingPermitLuaRepository.finalizeActive(userId, token, String.valueOf(storeId), reservationId, ttlTo3am); + + // 4) 응답 + Long rank = waitingUserRedisRepository.getRank(storeId, userId); + return WaitingResponseDto.builder() + .reservationNumber(reservationId) + .rank(rank == null ? -1 : rank.intValue() + 1) + .partySize(dto.getPartySize() == null ? 0 : dto.getPartySize()) + .build(); + + } catch (RuntimeException e) { + // 실패 시 임대 반납 + waitingPermitLuaRepository.releaseLease(userId, token); + throw e; + } } public WaitingResponseDto myWaitingInfo(Long storeId, CustomOAuth2User customOAuth2User) { @@ -140,18 +219,28 @@ public boolean cancelWaiting(Long storeId, CustomOAuth2User customOAuth2User) { reservationRepository.save(reservation); - return removed; + waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), reservationNumber); + return true; + // return removed; } //TODO 성능 개선 필요 public List getAllMyWaitings(CustomOAuth2User customOAuth2User) { String userId = customOAuth2User.getUserId().toString(); - // 1) 현재 SCAN 기반으로 얻어온 storeId 리스트 - List storeIds = waitingUserRedisRepository.getUserWaitingStoreIds(userId); - if (storeIds.isEmpty()) + Set members = waitingPermitLuaRepository.getActiveMembers(userId); + if (members.isEmpty()) return Collections.emptyList(); + // 1) 현재 SCAN 기반으로 얻어온 storeId 리스트 + // List storeIds = waitingUserRedisRepository.getUserWaitingStoreIds(userId); + // if (storeIds.isEmpty()) + // return Collections.emptyList(); + List storeIds = members.stream() + .map(m -> Long.parseLong(m.substring(0, m.indexOf(':')))) + .distinct() + .toList(); + // 2) Store, Department 배치 조회 List stores = storeRepository.findAllWithDepartmentByStoreIdIn(storeIds); Map storeMap = stores.stream() From 4319eff89851acbaa3e274fd4c2541776a1cf7bd Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 12 Aug 2025 20:09:33 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test(Reservation):=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9C=A0=EB=8B=9B=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReservationServiceConcurrencyTest.java | 54 ++++-- .../service/ReservationServiceTest.java | 181 ++++++++++++++---- 2 files changed, 177 insertions(+), 58 deletions(-) diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java index fd7b3614..2ae1d641 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java @@ -26,6 +26,7 @@ import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.repository.WaitingPermitLuaRepository; import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; import com.nowait.common.enums.Role; import com.nowait.domaincoreredis.common.util.RedisKeyUtils; @@ -50,14 +51,15 @@ class ReservationServiceConcurrencyTest { static ReservationService reservationService; // Mockito로 대체할 의존성들 - static ReservationRepository reservationRepo; - static StoreRepository storeRepo; - static UserRepository userRepo; - static DepartmentRepository deptRepo; - static StoreImageRepository storeImageRepo; + static ReservationRepository reservationRepo; + static StoreRepository storeRepo; + static UserRepository userRepo; + static DepartmentRepository deptRepo; + static StoreImageRepository storeImageRepo; + static WaitingPermitLuaRepository waitingPermitRepo; - private static final Long STORE_ID = 100L; - private static final int THREAD_COUNT = 50; + private static final Long STORE_ID = 100L; + private static final int THREAD_COUNT = 50; @BeforeAll static void setupAll() { @@ -71,22 +73,27 @@ static void setupAll() { // 2) 실제 Redis 리포지토리 객체 생성 waitingRepo = new WaitingUserRedisRepository(redisTemplate); + waitingPermitRepo = new WaitingPermitLuaRepository(redisTemplate); // 3) Mockito mock 인스턴스 생성 reservationRepo = mock(ReservationRepository.class); - storeRepo = mock(StoreRepository.class); - userRepo = mock(UserRepository.class); - deptRepo = mock(DepartmentRepository.class); - storeImageRepo = mock(StoreImageRepository.class); + storeRepo = mock(StoreRepository.class); + userRepo = mock(UserRepository.class); + deptRepo = mock(DepartmentRepository.class); + storeImageRepo = mock(StoreImageRepository.class); // 4) store/user 유효성 검증 스텁 Store mockStore = mock(Store.class); when(mockStore.getIsActive()).thenReturn(true); when(storeRepo.findById(anyLong())).thenReturn(Optional.of(mockStore)); - User mockUser = mock(User.class); - when(mockUser.getRole()).thenReturn(Role.USER); - when(userRepo.findById(anyLong())).thenReturn(Optional.of(mockUser)); + when(userRepo.findById(anyLong())).thenAnswer(inv -> { + Long uid = inv.getArgument(0, Long.class); + User u = mock(User.class); + when(u.getRole()).thenReturn(Role.USER); + when(u.getId()).thenReturn(uid); + return Optional.of(u); + }); // 5) ReservationService 실체 생성 reservationService = new ReservationService( @@ -96,6 +103,7 @@ static void setupAll() { waitingRepo, deptRepo, storeImageRepo, + waitingPermitRepo, redisTemplate ); } @@ -103,11 +111,14 @@ static void setupAll() { @BeforeEach void clearRedis() { // 매 테스트마다 Redis 키 초기화 - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingKeyPrefix() + "*")); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingKeyPrefix() + "*")); redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + "*")); - redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingStatusKeyPrefix() + "*")); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingStatusKeyPrefix() + "*")); redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationNumberKey(STORE_ID) + ":*")); + redisTemplate.delete(redisTemplate.keys("waiting:user:*")); + redisTemplate.delete(RedisKeyUtils.buildReservationNumberKey(STORE_ID)); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); } @Test @@ -115,11 +126,12 @@ void clearRedis() { void concurrentRegisterWaiting() throws InterruptedException { // --- Given --- // 50개 스레드를 준비하고, 동시에 시작/종료를 제어할 CountDownLatch - ExecutorService exec = Executors.newFixedThreadPool(THREAD_COUNT); - CountDownLatch startLatch = new CountDownLatch(1); + ExecutorService exec = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch finishLatch = new CountDownLatch(THREAD_COUNT); // 결과를 수집할 스레드 안전 리스트 List responses = Collections.synchronizedList(new ArrayList<>()); + List errors = Collections.synchronizedList(new ArrayList<>()); // THREAD_COUNT개의 작업을 스레드풀에 제출 for (int i = 0; i < THREAD_COUNT; i++) { @@ -139,9 +151,8 @@ void concurrentRegisterWaiting() throws InterruptedException { .build(); WaitingResponseDto responseDto = reservationService.registerWaiting(STORE_ID, user, dto); responses.add(responseDto); - - } catch (Exception e) { - fail("예외 발생: " + e.getMessage()); + } catch (Throwable t) { + errors.add(t); } finally { // 작업 완료 신고 finishLatch.countDown(); @@ -158,6 +169,7 @@ void concurrentRegisterWaiting() throws InterruptedException { // --- Then --- // 1) 모든 스레드가 제시간에 완료되었는가? assertTrue(completedInTime, "스레드가 제시간에 완료되지 않았습니다."); + assertTrue(errors.isEmpty(), "스레드 예외 발생: " + errors); // 2) 반환된 DTO 개수가 50개인가? assertEquals(THREAD_COUNT, responses.size(), "전체 응답 수 불일치"); diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java index 36b7deb7..0389a72f 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java @@ -1,8 +1,10 @@ package com.nowait.applicationuser.reservation.service; +import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.util.Collections; @@ -15,19 +17,24 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto; import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto; import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.repository.WaitingPermitLuaRepository; import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; import com.nowait.common.enums.ReservationStatus; import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; import com.nowait.domaincorerdb.reservation.entity.Reservation; import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; import com.nowait.domaincorerdb.store.entity.Store; import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; import com.nowait.domaincorerdb.store.repository.StoreRepository; import com.nowait.domaincorerdb.user.entity.User; import com.nowait.domaincorerdb.user.exception.UserNotFoundException; @@ -41,38 +48,13 @@ public class ReservationServiceTest { @Mock private UserRepository userRepository; @Mock private WaitingUserRedisRepository waitingRepo; @Mock private ReservationRepository reservationRepository; - @InjectMocks private ReservationService service; - - - @Test - @DisplayName("registerWaiting: 성공 시 WaitingResponseDto 반환") - void registerWaiting_Success() { - // Given - Long storeId = 10L; - CustomOAuth2User user = mock(CustomOAuth2User.class); - when(user.getUserId()).thenReturn(100L); - ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); - Store store = mock(Store.class); - when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); - when(store.getIsActive()).thenReturn(true); - User domainUser = mock(User.class); - when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); - when(domainUser.getRole()).thenReturn(Role.USER); - when(waitingRepo.addToWaitingQueue(eq(storeId), eq("100"), eq(2), anyLong())) - .thenReturn("10-20250804-0001"); - when(waitingRepo.getRank(storeId, "100")).thenReturn(4L); + @Mock private WaitingPermitLuaRepository waitingPermitLuaRepository; + @Mock private DepartmentRepository departmentRepository; + @Mock private StoreImageRepository storeImageRepository; + @Mock private RedisTemplate redisTemplate; - // When - WaitingResponseDto result = service.registerWaiting(storeId, user, dto); + @InjectMocks private ReservationService service; - // Then - assertNotNull(result); - assertEquals("10-20250804-0001", result.getReservationNumber()); - assertEquals(5, result.getRank()); - assertEquals(2, result.getPartySize()); - // Redis 등록 호출 검증 - verify(waitingRepo).addToWaitingQueue(eq(storeId), eq("100"), eq(2), anyLong()); - } @Test @DisplayName("registerWaiting: 스토어 없음 예외") @@ -129,18 +111,26 @@ void cancelWaiting_Success() { } @Test - @DisplayName("getAllMyWaitings: 대기 없음 시 빈 리스트 반환") - void getAllMyWaitings_Empty() { + @DisplayName("getAllMyWaitings: active가 비어 있으면 빈 리스트 반환") + void getAllMyWaitings_Empty_GWT() { // Given - CustomOAuth2User user = mock(CustomOAuth2User.class); - when(user.getUserId()).thenReturn(40L); - when(waitingRepo.getUserWaitingStoreIds("40")).thenReturn(Collections.emptyList()); + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + when(waitingPermitLuaRepository.getActiveMembers("100")) + .thenReturn(Collections.emptySet()); // When - List list = service.getAllMyWaitings(user); + List result = service.getAllMyWaitings(principal); // Then - assertTrue(list.isEmpty()); + assertThat(result).isEmpty(); + + // 불필요한 스텁/호출이 없도록 보장 + verify(waitingPermitLuaRepository).getActiveMembers("100"); + verifyNoMoreInteractions(waitingPermitLuaRepository); + // storeRepository 등은 호출되지 않아야 함 + verifyNoInteractions(storeRepository, departmentRepository, storeImageRepository, waitingRepo); } @@ -255,4 +245,121 @@ void create_StoreDisabledException() { ) ); } + + @Test + @DisplayName("registerWaiting: 성공 시 WaitingResponseDto 반환") + void registerWaiting_Success_GWT() { + // Given + Long storeId = 10L; + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); + + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + + User domainUser = mock(User.class); + when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); + when(domainUser.getRole()).thenReturn(Role.USER); + // 서비스가 user.getId()를 쓰는 경우 필수 + when(domainUser.getId()).thenReturn(100L); + + when(waitingRepo.calculateTTLUntilNext03AM()).thenReturn(Duration.ofHours(1)); + when(waitingRepo.isUserWaiting(storeId, "100")).thenReturn(false); + when(waitingPermitLuaRepository.acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any())) + .thenReturn(true); + + when(waitingRepo.addToWaitingQueue(eq(storeId), eq("100"), eq(2), anyLong())) + .thenReturn("10-20250804-0001"); + when(waitingRepo.getRank(storeId, "100")).thenReturn(4L); + + // When + WaitingResponseDto result = service.registerWaiting(storeId, principal, dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getReservationNumber()).isEqualTo("10-20250804-0001"); + assertThat(result.getRank()).isEqualTo(5); + assertThat(result.getPartySize()).isEqualTo(2); + + verify(waitingPermitLuaRepository).acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any()); + verify(waitingRepo).addToWaitingQueue(eq(storeId), eq("100"), eq(2), anyLong()); + verify(waitingPermitLuaRepository).finalizeActive(eq("100"), anyString(), eq(String.valueOf(storeId)), eq("10-20250804-0001"), any()); + verifyNoMoreInteractions(waitingPermitLuaRepository, waitingRepo); + } + + + @Test + @DisplayName("registerWaiting: 동일 매장 재요청 시 기존 정보 반환(임대 미소비)") + void registerWaiting_Duplicate_SameStore_GWT() { + // Given + Long storeId = 10L; + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); + + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + + User domainUser = mock(User.class); + when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); + when(domainUser.getRole()).thenReturn(Role.USER); + when(domainUser.getId()).thenReturn(100L); + + // 이미 해당 매장에 있는 상태 + when(waitingRepo.isUserWaiting(storeId, "100")).thenReturn(true); + when(waitingRepo.getRank(storeId, "100")).thenReturn(4L); + when(waitingRepo.getPartySize(storeId, "100")).thenReturn(2); + when(waitingRepo.getReservationId(storeId, "100")).thenReturn("10-20250804-0001"); + + // When + WaitingResponseDto result = service.registerWaiting(storeId, principal, dto); + + // Then + assertThat(result.getReservationNumber()).isEqualTo("10-20250804-0001"); + assertThat(result.getRank()).isEqualTo(5); + assertThat(result.getPartySize()).isEqualTo(2); + + verify(waitingPermitLuaRepository, never()).acquireLease(any(), any(), anyLong(), anyLong(), anyInt(), any()); + verify(waitingRepo, never()).addToWaitingQueue(anyLong(), anyString(), anyInt(), anyLong()); + } + + + @Test + @DisplayName("registerWaiting: 유저 한도(3개) 초과 시 예외") + void registerWaiting_LimitExceeded_GWT() { + // Given + Long storeId = 40L; + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); + + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + + User domainUser = mock(User.class); + when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); + when(domainUser.getRole()).thenReturn(Role.USER); + when(domainUser.getId()).thenReturn(100L); + + when(waitingRepo.calculateTTLUntilNext03AM()).thenReturn(Duration.ofHours(1)); + when(waitingRepo.isUserWaiting(storeId, "100")).thenReturn(false); + // 임대 3회 모두 실패하도록 + when(waitingPermitLuaRepository.acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any())) + .thenReturn(false); + + // When & Then + assertThatThrownBy(() -> service.registerWaiting(storeId, principal, dto)) + .isInstanceOf(com.nowait.domaincorerdb.reservation.exception.UserWaitingLimitExceededException.class) + .hasMessageContaining("유저당 웨이팅 가능 개수"); + + verify(waitingRepo, never()).addToWaitingQueue(anyLong(), anyString(), anyInt(), anyLong()); + verify(waitingPermitLuaRepository, atLeastOnce()).acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any()); + } }