diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java index e49dda65..dbc2ed03 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java @@ -63,12 +63,14 @@ public ResponseEntity registerWaiting( public ResponseEntity cancelWaiting( @AuthenticationPrincipal CustomOAuth2User customOAuth2User, @PathVariable String publicCode, - @RequestBody CancelWaitingRequest request + @RequestBody CancelWaitingRequest request, + HttpServletRequest httpServletRequest ) { CancelWaitingResponse cancelWaitingResponse = waitingService.cancelWaiting( customOAuth2User, publicCode, - request + request, + httpServletRequest ); return ResponseEntity diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java new file mode 100644 index 00000000..92bb2346 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WaitingCancelIdempotencyValue { + private String state; + private CancelWaitingResponse response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java index 791b3880..3fe4a3d8 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java @@ -7,7 +7,9 @@ import org.springframework.stereotype.Repository; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import lombok.RequiredArgsConstructor; @@ -21,22 +23,42 @@ public class WaitingIdempotencyRepository { private static final Duration TTL = Duration.ofMinutes(10); + // 멱등키 조회 메서드 public Optional findByKey(String key) { - String value = redisTemplate.opsForValue().get(key); + String idempotencyValue = redisTemplate.opsForValue().get(key); - if (value == null) { + if (idempotencyValue == null) { return Optional.empty(); } try { return Optional.of( - objectMapper.readValue(value, WaitingIdempotencyValue.class) + objectMapper.readValue(idempotencyValue, WaitingIdempotencyValue.class) ); } catch (Exception e) { throw new IllegalArgumentException("Failed to deserialize value from Redis", e); } } + // 멱등키 조회 메서드 + public Optional findByCancelKey(String key) { + String idempotencyValue = redisTemplate.opsForValue().get(key); + + if (idempotencyValue == null) { + return Optional.empty(); + } + + try { + return Optional.of( + objectMapper.readValue(idempotencyValue, WaitingCancelIdempotencyValue.class) + ); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize value from Redis", e); + } + } + + + // 멱등키 저장 메서드 - 대기 등록 public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { WaitingIdempotencyValue waitingIdempotencyValue = new WaitingIdempotencyValue( "COMPLETED", @@ -50,4 +72,19 @@ public void saveIdempotencyValue(String key, RegisterWaitingResponse response) { throw new IllegalArgumentException("Failed to serialize value for Redis", e); } } + + // 멱등키 저장 메서드 - 대기 취소 + public void saveCancelIdempotencyValue(String key, CancelWaitingResponse response) { + WaitingCancelIdempotencyValue waitingIdempotencyValue = new WaitingCancelIdempotencyValue( + "COMPLETED", + response + ); + + try { + String jsonValue = objectMapper.writeValueAsString(waitingIdempotencyValue); + redisTemplate.opsForValue().set(key, jsonValue, TTL); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to serialize value for Redis", e); + } + } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java index 822d31c3..cfcd2476 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Optional; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -13,6 +12,7 @@ import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse; import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; @@ -59,13 +59,11 @@ public class WaitingService { @Transactional public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) { - String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); - - // TODO 멱등성 검증 로직 점검 필요 - Optional existingIdempotencyValue = waitingIdempotencyRepository.findByKey(idempotentKey); - if (existingIdempotencyValue.isPresent()) { - log.info("Existing idempotency key found: {}", idempotentKey); - return existingIdempotencyValue.get().getResponse(); + // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) + RegisterWaitingResponse registerWaitingResponse = validateIdempotency(httpServletRequest); + if (registerWaitingResponse != null) { + log.info("Idempotent request detected. Returning existing response."); + return registerWaitingResponse; } // TODO 유저 및 주점 존재 검증은 공통으로 많이 쓰이니 AOP로 빼는게 좋을 듯 @@ -75,19 +73,15 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri User user = userRepository.findById(oAuth2User.getUserId()) .orElseThrow(UserNotFoundException::new); + // 일일 가능 웨이팅 최대 개수 초과 검증 + // TODO race condition 발생 가능성 점검 필요, DB 저장 로직 실패 시 롤백 처리 필요 + waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); + // 웨이팅 고유 번호 생성 - YYYYMMDD-storeId-sequence number 일련 번호 Long storeId = store.getStoreId(); LocalDateTime timestamp = LocalDateTime.now(); String waitingNumber = generateWaitingNumber(storeId, timestamp); - // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 - // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.WAITING.name()); - - - // 일일 가능 웨이팅 최대 개수 초과 검증 - // TODO race condition 발생 가능성 점검 필요 - waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); - // DB에 상태 값 저장 Reservation reservation = Reservation.builder() .reservationNumber(waitingNumber) @@ -115,14 +109,20 @@ public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, Stri .partySize(waitingRequest.getPartySize()) .build(); - // 멱등키가 있다면 멱등 응답 저장 - waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + // TODO 멱등키 응답 실패 시 어떻게 처리할 지 점검 필요 + saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); return response; } @Transactional - public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request) { + public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request, HttpServletRequest httpServletRequest) { + // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) + CancelWaitingResponse cancelWaitingResponse = validateCancelIdempotency(httpServletRequest); + if (cancelWaitingResponse != null) { + log.info("Idempotent request detected. Returning existing response."); + return cancelWaitingResponse; + } Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode).orElseThrow(StoreNotFoundException::new); Long storeId = store.getStoreId(); @@ -130,10 +130,6 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p User user = userRepository.findById(oAuth2User.getUserId()) .orElseThrow(UserNotFoundException::new); - // TODO 멱등키 검증 로직 점검 필요 - // String idempotentKey = generateIdempotentKey(storeId, user.getId()); - // waitingRedisRepository.idempotentKeyKeyExists(idempotentKey, ReservationStatus.CANCELLED.name()); - // DB 웨이팅 상태 취소 처리 Reservation reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) .orElseThrow(ReservationNotFoundException::new); @@ -143,28 +139,62 @@ public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String p // Redis 대기열 취소 이벤트 발행 waitingRedisRepository.removeWaiting(storeId, user.getId()); - return CancelWaitingResponse.builder() + CancelWaitingResponse response = CancelWaitingResponse.builder() .waitingNumber(reservation.getReservationNumber()) .storeId(storeId) .reservationStatus(reservation.getStatus()) .canceledAt(reservation.getUpdatedAt()) .message("대기 취소가 완료되었습니다.") .build(); + + // 멱등키가 있다면 멱등 응답 저장 + waitingIdempotencyRepository.saveCancelIdempotencyValue(httpServletRequest.getHeader("Idempotency-Key"), response); + + return response; + } + + // 멱등키 검증 메서드 + private RegisterWaitingResponse validateIdempotency(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 + // TODO 멱등성 검증 로직 점검 필요 + return waitingIdempotencyRepository.findByKey(idempotentKey) + .map(WaitingIdempotencyValue::getResponse) + .orElse(null); + } + + private CancelWaitingResponse validateCancelIdempotency(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + // 멱등키 검증 - 이미 동일한 멱등키로 등록된 웨이팅이 있는지 확인 + // TODO 멱등성 검증 로직 점검 필요 + return waitingIdempotencyRepository.findByCancelKey(idempotentKey) + .map(WaitingCancelIdempotencyValue::getResponse) + .orElse(null); } + // 멱등키 응답 저장 메서드 + private void saveIdempotencyResponse(String idempotentKey, RegisterWaitingResponse response) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + waitingIdempotencyRepository.saveIdempotencyValue(idempotentKey, response); + } + } + + // 현재 대기 인원 수 조회 public GetWaitingSizeResponse getWaitingCount(CustomOAuth2User oAuth2User, String publicCode) { Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) .orElseThrow(StoreNotFoundException::new); - Long storeId = store.getStoreId(); - User user = userRepository.findById(oAuth2User.getUserId()) .orElseThrow(UserNotFoundException::new); Department department = departmentRepository.findById(store.getDepartmentId()) .orElseThrow(DepartmentNotFoundException::new); + Long storeId = store.getStoreId(); + Long waitingCount = waitingRedisRepository.getWaitingCount(storeId); return GetWaitingSizeResponse.builder() @@ -190,8 +220,4 @@ private String generateWaitingNumber(Long storeId, LocalDateTime timestamp) { // 4) 최종 ID 조합 return today + "-" + storeId + "-" + seqStr; } - - private String generateIdempotentKey(Long storeId, Long userId) { - return "idempotentKey" + ":" + storeId + ":" + userId; - } } diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java index 61a0cd4a..cb5b8f84 100644 --- a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,14 +16,18 @@ import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; import com.nowait.domaincorerdb.reservation.entity.Reservation; 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.repository.StoreRepository; import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; @@ -42,115 +49,277 @@ class WaitingServiceTest { @Mock private ReservationRepository reservationRepository; @Mock - WaitingIdempotencyRepository waitingIdempotencyRepository; + private WaitingIdempotencyRepository waitingIdempotencyRepository; + @Mock + private HttpServletRequest httpServletRequest; + @Mock + private CustomOAuth2User customOAuth2User; + + private static final String IDEMPOTENCY_KEY = "550e8400-e29b-41d4-a716-446655440000"; + + @BeforeEach + void setUp() { + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + } + + @Test + @DisplayName("멱등키 있는 경우 미리 저장된 응답이 반환 되는지 테스트") + void registerWaiting_idempotentKeyExists() { + // given + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + RegisterWaitingResponse idempotentResponse = RegisterWaitingResponse.builder() + .waitingNumber("20260201-2-0001") + .partySize(4) + .build(); + + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.of(new WaitingIdempotencyValue( + "COMPLETED", + idempotentResponse + ))); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + ); + + // then + assertThat(response).isSameAs(idempotentResponse); + + verify(storeRepository, never()).findByPublicCodeAndDeletedFalse(anyString()); + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + } @Test - @DisplayName("웨이팅 정상 등록 테스트") - void registerWaiting() { + @DisplayName("Idempotency-Key가 blank이면 멱등 로직을 타지 않는다") + void registerWaiting_idempotentKeyNotExists() { // given CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); RegisterWaitingRequest request = new RegisterWaitingRequest(4); + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(" "); + + Long userId = 10L; String publicCode = "ZiVXAD1vVr5b"; - Long userId = 1L; - HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); Store store = Store.builder().publicCode(publicCode).build(); User user = User.builder().id(userId).build(); + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + } + + @Test + @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") + void registerWaiting_success() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); - when(customOAuth2User.getUserId()).thenReturn(userId); + when(customOAuth2User.getUserId()).thenReturn(10L); when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); - RegisterWaitingResponse response = waitingService.registerWaiting(customOAuth2User, publicCode, request, httpServletRequest); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); // then - verify(waitingIdempotencyRepository).findByKey(anyString()); + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); verify(reservationRepository).save(any(Reservation.class)); verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + verify(waitingIdempotencyRepository).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + } - assertThat(response.getPartySize()).isEqualTo(4); - assertThat(response.getWaitingNumber()).isNotNull(); + @Test + @DisplayName("DB 저장 중 예외 발생 시 이벤트 발행 및 멱등 저장이 수행되지 않음") + void registerWaiting_dbSaveException() { + // given + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + Store store = Store.builder().publicCode("ZiVXAD1vVr5b").build(); + User user = User.builder().id(10L).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse("ZiVXAD1vVr5b")) + .thenReturn(Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(10L)) + .thenReturn(Optional.of(user)); + + doNothing().when(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + doThrow(new RuntimeException("DB 저장 실패")) + .when(reservationRepository) + .save(any(Reservation.class)); + + + // when & then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(RuntimeException.class); + + verify(eventPublisher, never()).publishEvent(any(AddWaitingRegisterEvent.class)); + verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("웨이팅 개수 제한 초과 시 예외 발생 테스트") + void registerWaiting_exceedWaitingLimit() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doThrow(new UserWaitingLimitExceededException()) + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(10L, 3L); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(UserWaitingLimitExceededException.class); + + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyValue(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); + } + + @Test + @DisplayName("존재하지 않는 publicCode이면 StoreNotFoundException 발생") + void registerWaiting_storeNotFound() { + // given + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse("invalidCode")) + .thenReturn(Optional.empty()); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "invalidCode", + request, + httpServletRequest + )).isInstanceOf(StoreNotFoundException.class); + + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); } - // @Test - // @DisplayName("DB 저장 실패 시 Redis 대기열 추가 이벤트 실행 되지 않음") - // void registerWaiting_dbSaveFail() { - // // given - // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - // RegisterWaitingRequest request = new RegisterWaitingRequest(4); - // - // Long storeId = 1L; - // Long userId = 1L; - // - // Store store = Store.builder().storeId(storeId).build(); - // User user = User.builder().id(userId).build(); - // - // // when - // when(customOAuth2User.getUserId()).thenReturn(userId); - // when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); - // when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - // - // when(reservationRepository.save(any(Reservation.class))).thenThrow(new RuntimeException("DB 저장 실패")); - // - // // then - // assertThatThrownBy(() -> - // waitingService.registerWaiting(customOAuth2User, storeId, request) - // ).isInstanceOf(RuntimeException.class); - // - // verify(waitingRedisRepository).idempotentKeyKeyExists(anyString(), eq("WAITING")); - // verify(eventPublisher, never()).publishEvent(any()); - // } - // - // @Test - // @DisplayName("없는 주점에 웨이팅 등록 시도 시 예외 발생") - // void registerWaiting_storeNotFound() { - // // given - // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - // RegisterWaitingRequest request = new RegisterWaitingRequest(4); - // - // Long storeId = -1L; // 존재하지 않는 주점 ID - // Long userId = 1L; - // - // // when - // when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); - // - // // then - // assertThatThrownBy(() -> - // waitingService.registerWaiting(customOAuth2User, storeId, request) - // ).isInstanceOf(StoreNotFoundException.class); - // - // verify(userRepository, never()).findById(anyLong()); - // verify(waitingRedisRepository, never()).idempotentKeyKeyExists(anyString(), eq("WAITING")); - // verify(reservationRepository, never()).save(any(Reservation.class)); - // verify(eventPublisher, never()).publishEvent(any()); - // } - // - // @Test - // @DisplayName("없는 유저가 웨이팅 등록 시도 시 예외 발생") - // void registerWaiting_userNotFound() { - // // given - // CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); - // RegisterWaitingRequest request = new RegisterWaitingRequest(4); - // - // Long storeId = 1L; - // Long userId = -1L; // 존재 하지 않는 유저 ID - // - // Store store = Store.builder().storeId(storeId).build(); - // - // // when - // when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); - // when(customOAuth2User.getUserId()).thenReturn(userId); - // when(userRepository.findById(userId)).thenReturn(Optional.empty()); - // - // // then - // assertThatThrownBy(() -> - // waitingService.registerWaiting(customOAuth2User, storeId, request) - // ).isInstanceOf(UserNotFoundException.class); - // - // verify(waitingRedisRepository, never()).idempotentKeyKeyExists(anyString(), eq("WAITING")); - // verify(reservationRepository, never()).save(any(Reservation.class)); - // verify(eventPublisher, never()).publishEvent(any()); - // } + @Test + @DisplayName("존재하지 않는 userId이면 UserNotFoundException 발생") + void registerWaiting_userNotFound() { + // given + when(waitingIdempotencyRepository.findByKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)) + .thenReturn(Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(10L)) + .thenReturn(Optional.empty()); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + )).isInstanceOf(UserNotFoundException.class); + + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + } } diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java index f5f69441..b0b74bc2 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java @@ -166,22 +166,6 @@ public void removeWaiting(Long storeId, Long userId) { } } - // 웨이팅 등록 요청 시 멱등키 검증 - public void idempotentKeyKeyExists(String idempotentKey, String status) { - Boolean success = redisTemplate.opsForValue() - .setIfAbsent( - idempotentKey, - status, - Duration.ofSeconds(10) - ); - - - // TODO 멱등하지 않은 요청 응답값 검토 필요 - if (Boolean.FALSE.equals(success)) { - throw new AlreadyWaitingException(); - } - } - public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId));