From 28f0c1d88033cd73d0567f55092a1f7ef7b27bad Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Mon, 4 Aug 2025 20:30:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test(Reservation):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nowait-app-user-api/build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nowait-app-user-api/build.gradle b/nowait-app-user-api/build.gradle index c87e07e1..ab3af0b2 100644 --- a/nowait-app-user-api/build.gradle +++ b/nowait-app-user-api/build.gradle @@ -82,6 +82,12 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testImplementation 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'com.redis:testcontainers-redis:2.2.4' } test { From 1ed5aabe10636d35d82c4fc9105feafd642c284e Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Mon, 4 Aug 2025 20:30:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test(Reservation):=20=EC=9B=A8=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20=EA=B8=B0=EB=B3=B8=20=EC=9C=A0=EB=8B=9B=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReservationServiceTest.java | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java 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 new file mode 100644 index 00000000..36b7deb7 --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java @@ -0,0 +1,258 @@ +package com.nowait.applicationuser.reservation.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +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 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.WaitingUserRedisRepository; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.enums.Role; +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.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.domainuserrdb.oauth.dto.CustomOAuth2User; + +@ExtendWith(MockitoExtension.class) +public class ReservationServiceTest { + + @Mock private StoreRepository storeRepository; + @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); + + // When + WaitingResponseDto result = service.registerWaiting(storeId, user, dto); + + // 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: 스토어 없음 예외") + void registerWaiting_StoreNotFound() { + // Given + Long storeId = 10L; + when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); + + // When/Then + assertThrows(StoreNotFoundException.class, + () -> service.registerWaiting(storeId, mock(CustomOAuth2User.class), null) + ); + } + + @Test + @DisplayName("myWaitingInfo: 정상 조회") + void myWaitingInfo_Success() { + // Given + Long storeId = 5L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(20L); + when(waitingRepo.getRank(storeId, "20")).thenReturn(0L); + when(waitingRepo.getPartySize(storeId, "20")).thenReturn(3); + when(waitingRepo.getReservationId(storeId, "20")).thenReturn("5-20250804-0001"); + + // When + WaitingResponseDto info = service.myWaitingInfo(storeId, user); + + // Then + assertNotNull(info); + assertEquals("5-20250804-0001", info.getReservationNumber()); + assertEquals(1, info.getRank()); + assertEquals(3, info.getPartySize()); + } + + @Test + @DisplayName("cancelWaiting: 성공 시 true 반환 및 DB 저장 호출") + void cancelWaiting_Success() { + // Given + Long storeId = 7L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(30L); + when(waitingRepo.getReservationId(storeId, "30")).thenReturn("RID-1"); + when(waitingRepo.getPartySize(storeId, "30")).thenReturn(4); + when(waitingRepo.getWaitingTimestamp(storeId, "30")).thenReturn(Instant.now().toEpochMilli()); + when(waitingRepo.removeWaiting(storeId, "30")).thenReturn(true); + + // When + boolean removed = service.cancelWaiting(storeId, user); + + // Then + assertTrue(removed); + verify(reservationRepository).save(any(Reservation.class)); + } + + @Test + @DisplayName("getAllMyWaitings: 대기 없음 시 빈 리스트 반환") + void getAllMyWaitings_Empty() { + // Given + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(40L); + when(waitingRepo.getUserWaitingStoreIds("40")).thenReturn(Collections.emptyList()); + + // When + List list = service.getAllMyWaitings(user); + + // Then + assertTrue(list.isEmpty()); + } + + + @Test + @DisplayName("create(DB): 성공 시 DTO 반환") + void create_Success() { + // Given + Long storeId = 2L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(50L); + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(5).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(50L)).thenReturn(Optional.of(domainUser)); + when(reservationRepository.existsByUserAndStoreAndStatusIn(eq(domainUser), eq(store), anyList())) + .thenReturn(false); + Reservation saved = Reservation.builder() + .id(99L) + .store(store) + .user(domainUser) + .requestedAt(LocalDateTime.now()) + .status(ReservationStatus.WAITING) + .partySize(5) + .build(); + when(reservationRepository.save(any(Reservation.class))).thenReturn(saved); + + // When + ReservationCreateResponseDto res = service.create(storeId, user, dto); + + // Then + assertNotNull(res); + assertEquals(99L, res.getId()); + assertEquals(5, res.getPartySize()); + } + + @Test + @DisplayName("create(DB): 중복 예약 예외") + void create_DuplicateException() { + // Given + Long storeId = 2L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(50L); + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(1).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(50L)).thenReturn(Optional.of(domainUser)); + when(reservationRepository.existsByUserAndStoreAndStatusIn(eq(domainUser), eq(store), anyList())) + .thenReturn(true); + + // When/Then + assertThrows(DuplicateReservationException.class, + () -> service.create(storeId, user, dto) + ); + } + + @Test + @DisplayName("create(DB): 사용자 없음 예외") + void create_UserNotFound() { + // Given + Long storeId = 2L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(50L); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(mock(Store.class))); + when(userRepository.findById(50L)).thenReturn(Optional.empty()); + + // When/Then + assertThrows(UserNotFoundException.class, + () -> service.create(storeId, user, ReservationCreateRequestDto.builder().partySize(1).build()) + ); + } + + @Test + @DisplayName("create(DB): 스토어 없음 예외") + void create_StoreNotFound() { + // Given + Long storeId = 3L; + when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); + + // When/Then + assertThrows(StoreNotFoundException.class, + () -> service.create(storeId, mock(CustomOAuth2User.class), ReservationCreateRequestDto.builder().partySize(1).build()) + ); + } + + @Test + @DisplayName("create(DB): 스토어 비활성화 예외") + void create_StoreDisabledException() { + // Given + Long storeId = 4L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(60L); + + // 1) 사용자 조회 스텁 추가 + User domainUser = mock(User.class); + when(userRepository.findById(60L)).thenReturn(Optional.of(domainUser)); + + // 2) 스토어 조회 및 비활성화 스텁 + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(false); + + // When / Then + assertThrows(StoreWaitingDisabledException.class, + () -> service.create( + storeId, + user, + ReservationCreateRequestDto.builder().partySize(2).build() + ) + ); + } +} From 484a79b244e3c3a2501d8a7740168d29b1cf1ad3 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Mon, 4 Aug 2025 20:30:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(Reservation):=20=EB=A9=80=ED=8B=B0=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=20=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 | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java 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 new file mode 100644 index 00000000..fd7b3614 --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java @@ -0,0 +1,176 @@ +package com.nowait.applicationuser.reservation.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; +import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; +import com.nowait.common.enums.Role; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; +import com.redis.testcontainers.RedisContainer; + +@Testcontainers +class ReservationServiceConcurrencyTest { + + @Container + static RedisContainer redis = new RedisContainer("redis:6.2.6"); + + static StringRedisTemplate redisTemplate; + static WaitingUserRedisRepository waitingRepo; + static ReservationService reservationService; + + // Mockito로 대체할 의존성들 + static ReservationRepository reservationRepo; + static StoreRepository storeRepo; + static UserRepository userRepo; + static DepartmentRepository deptRepo; + static StoreImageRepository storeImageRepo; + + private static final Long STORE_ID = 100L; + private static final int THREAD_COUNT = 50; + + @BeforeAll + static void setupAll() { + // 1) RedisTemplate 초기화 + LettuceConnectionFactory factory = new LettuceConnectionFactory( + redis.getHost(), + redis.getFirstMappedPort() + ); + factory.afterPropertiesSet(); + redisTemplate = new StringRedisTemplate(factory); + + // 2) 실제 Redis 리포지토리 객체 생성 + waitingRepo = new WaitingUserRedisRepository(redisTemplate); + + // 3) Mockito mock 인스턴스 생성 + reservationRepo = mock(ReservationRepository.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)); + + // 5) ReservationService 실체 생성 + reservationService = new ReservationService( + reservationRepo, + storeRepo, + userRepo, + waitingRepo, + deptRepo, + storeImageRepo, + redisTemplate + ); + } + + @BeforeEach + void clearRedis() { + // 매 테스트마다 Redis 키 초기화 + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingKeyPrefix() + "*")); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + "*")); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingStatusKeyPrefix() + "*")); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); + redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationNumberKey(STORE_ID) + ":*")); + } + + @Test + @DisplayName("동시 50명 대기 등록 테스트 (Given–When–Then)") + void concurrentRegisterWaiting() throws InterruptedException { + // --- Given --- + // 50개 스레드를 준비하고, 동시에 시작/종료를 제어할 CountDownLatch + ExecutorService exec = Executors.newFixedThreadPool(THREAD_COUNT); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(THREAD_COUNT); + // 결과를 수집할 스레드 안전 리스트 + List responses = Collections.synchronizedList(new ArrayList<>()); + + // THREAD_COUNT개의 작업을 스레드풀에 제출 + for (int i = 0; i < THREAD_COUNT; i++) { + final long uid = 1000 + i; + exec.submit(() -> { + try { + // 모든 스레드가 startLatch.await() 대기 중 + startLatch.await(); + + // 각 스레드마다 OAuth2User mocking + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(uid); + + // 실제 호출 + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder() + .partySize(2) + .build(); + WaitingResponseDto responseDto = reservationService.registerWaiting(STORE_ID, user, dto); + responses.add(responseDto); + + } catch (Exception e) { + fail("예외 발생: " + e.getMessage()); + } finally { + // 작업 완료 신고 + finishLatch.countDown(); + } + }); + } + + // --- When --- + // 모든 스레드를 동시에 시작 + startLatch.countDown(); + // 최대 15초 대기 + boolean completedInTime = finishLatch.await(15, TimeUnit.SECONDS); + + // --- Then --- + // 1) 모든 스레드가 제시간에 완료되었는가? + assertTrue(completedInTime, "스레드가 제시간에 완료되지 않았습니다."); + // 2) 반환된 DTO 개수가 50개인가? + assertEquals(THREAD_COUNT, responses.size(), "전체 응답 수 불일치"); + + // 3) 예약번호가 모두 유니크한가? + Set reservationIds = new HashSet<>(); + for (WaitingResponseDto r : responses) { + assertNotNull(r.getReservationNumber(), "예약번호가 null 입니다"); + reservationIds.add(r.getReservationNumber()); + } + assertEquals(THREAD_COUNT, reservationIds.size(), "예약번호가 중복되었습니다"); + + // 스레드풀 정리 + exec.shutdown(); + } + +} From 0cc63ec311cedafa9d87cc9f19e2216ede121da4 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 5 Aug 2025 18:22:56 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EC=9E=84=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/nowait/ApiAdminApplication.java | 1 - .../src/main/java/com/nowait/ApiUserApplication.java | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java b/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java index ae757172..1d8d85f7 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java @@ -14,4 +14,3 @@ public static void main(String[] args) { org.springframework.boot.SpringApplication.run(ApiAdminApplication.class, args); } } - diff --git a/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java b/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java index a08337be..2460ef23 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java +++ b/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java @@ -13,3 +13,4 @@ public static void main(String[] args) { SpringApplication.run(ApiUserApplication.class, args); } } +