diff --git a/src/main/java/nbc/ticketing/ticket911/common/aop/RedissonMultiLockAspect.java b/src/main/java/nbc/ticketing/ticket911/common/aop/RedissonMultiLockAspect.java index 21513e7..0ebab0f 100644 --- a/src/main/java/nbc/ticketing/ticket911/common/aop/RedissonMultiLockAspect.java +++ b/src/main/java/nbc/ticketing/ticket911/common/aop/RedissonMultiLockAspect.java @@ -17,13 +17,11 @@ import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import nbc.ticketing.ticket911.common.annotation.RedissonMultiLock; import nbc.ticketing.ticket911.domain.lock.LockRedisService; import nbc.ticketing.ticket911.infrastructure.redisson.exception.LockRedisException; import nbc.ticketing.ticket911.infrastructure.redisson.exception.code.LockRedisExceptionCode; -@Slf4j @Aspect @Component @RequiredArgsConstructor diff --git a/src/main/java/nbc/ticketing/ticket911/config/RedisConfig.java b/src/main/java/nbc/ticketing/ticket911/config/RedisConfig.java index f57f932..7a0ff52 100644 --- a/src/main/java/nbc/ticketing/ticket911/config/RedisConfig.java +++ b/src/main/java/nbc/ticketing/ticket911/config/RedisConfig.java @@ -22,7 +22,11 @@ public class RedisConfig { public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() - .setAddress("redis://" + redisHost + ":" + redisPort); + .setAddress("redis://" + redisHost + ":" + redisPort) + .setConnectionMinimumIdleSize(1) + .setConnectionPoolSize(5) + .setConnectTimeout(10000) + .setTimeout(10000); return Redisson.create(config); } diff --git a/src/main/java/nbc/ticketing/ticket911/infrastructure/redisson/RedissonLockRedisService.java b/src/main/java/nbc/ticketing/ticket911/infrastructure/redisson/RedissonLockRedisService.java index fe1c261..46f05a6 100644 --- a/src/main/java/nbc/ticketing/ticket911/infrastructure/redisson/RedissonLockRedisService.java +++ b/src/main/java/nbc/ticketing/ticket911/infrastructure/redisson/RedissonLockRedisService.java @@ -28,16 +28,8 @@ public T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit } try { return action.get(); - } catch (LockRedisException lre) { - throw lre; } catch (Throwable t) { - if (t instanceof RuntimeException re) { - throw re; - } - if (t instanceof Error err) { - throw err; - } - throw new LockRedisException(LockRedisExceptionCode.LOCK_PROCEED_FAIL); + throw wrapThrowable(t); } finally { lockRedisRepository.unlock(key); } @@ -45,8 +37,7 @@ public T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit @Override public T executeWithMultiLock(List keys, long waitTime, long leaseTime, TimeUnit timeUnit, - ThrowingSupplier action) - throws LockRedisException { + ThrowingSupplier action) { List lockedKeys = new ArrayList<>(); try { @@ -58,20 +49,19 @@ public T executeWithMultiLock(List keys, long waitTime, long leaseTi lockedKeys.add(key); } return action.get(); - } catch (LockRedisException lre) { - throw lre; } catch (Throwable t) { - if (t instanceof RuntimeException re) { - throw re; - } - if (t instanceof Error err) { - throw err; - } - throw new LockRedisException(LockRedisExceptionCode.LOCK_PROCEED_FAIL); + throw wrapThrowable(t); } finally { for (String key : lockedKeys) { lockRedisRepository.unlock(key); } } } + + private RuntimeException wrapThrowable(Throwable t) { + if (t instanceof LockRedisException e) return e; + if (t instanceof RuntimeException e) return e; + if (t instanceof Error e) throw e; + return new LockRedisException(LockRedisExceptionCode.LOCK_PROCEED_FAIL); + } } diff --git a/src/test/java/nbc/ticketing/ticket911/domain/booking/controller/BookingControllerTest.java b/src/test/java/nbc/ticketing/ticket911/domain/booking/controller/BookingControllerTest.java index c7bc42b..aa2d2f7 100644 --- a/src/test/java/nbc/ticketing/ticket911/domain/booking/controller/BookingControllerTest.java +++ b/src/test/java/nbc/ticketing/ticket911/domain/booking/controller/BookingControllerTest.java @@ -1,154 +1,98 @@ package nbc.ticketing.ticket911.domain.booking.controller; -import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; -import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; -import nbc.ticketing.ticket911.domain.auth.vo.AuthUser; +import com.fasterxml.jackson.databind.ObjectMapper; + +import nbc.ticketing.ticket911.domain.booking.application.BookingFacade; import nbc.ticketing.ticket911.domain.booking.application.BookingService; import nbc.ticketing.ticket911.domain.booking.dto.request.BookingRequestDto; -import nbc.ticketing.ticket911.domain.concert.entity.Concert; -import nbc.ticketing.ticket911.domain.concert.repository.ConcertRepository; -import nbc.ticketing.ticket911.domain.concertseat.entity.ConcertSeat; -import nbc.ticketing.ticket911.domain.concertseat.repository.ConcertSeatRepository; -import nbc.ticketing.ticket911.domain.seat.entity.Seat; -import nbc.ticketing.ticket911.domain.seat.repository.SeatRepository; -import nbc.ticketing.ticket911.domain.stage.entity.Stage; -import nbc.ticketing.ticket911.domain.stage.repository.StageRepository; -import nbc.ticketing.ticket911.domain.stage.status.StageStatus; -import nbc.ticketing.ticket911.domain.user.constant.UserRole; -import nbc.ticketing.ticket911.domain.user.entity.User; -import nbc.ticketing.ticket911.domain.user.repository.UserRepository; - -@SpringBootTest -@AutoConfigureMockMvc +import nbc.ticketing.ticket911.domain.booking.dto.response.BookingResponseDto; +import nbc.ticketing.ticket911.support.security.TestSecurityConfig; + +@WebMvcTest(controllers = BookingController.class) +@Import(TestSecurityConfig.class) class BookingControllerTest { @Autowired - private BookingService bookingService; + private MockMvc mockMvc; @Autowired - private UserRepository userRepository; + private ObjectMapper objectMapper; - @Autowired - private StageRepository stageRepository; + @MockitoBean + private BookingService bookingService; - @Autowired - private SeatRepository seatRepository; + @MockitoBean + private BookingFacade bookingFacade; - @Autowired - private ConcertRepository concertRepository; + private final BookingResponseDto mockResponseDto = BookingResponseDto.builder() + .id(1L) + .userEmail("test@test.com") + .userNickname("테스터") + .concertTitle("테스트 콘서트") + .stageName("테스트홀") + .startTime(LocalDateTime.now().plusDays(1)) + .updatedAt(LocalDateTime.now()) + .seatNames(List.of("A1", "A2")) + .build(); - @Autowired - private ConcertSeatRepository concertSeatRepository; - - private ConcertSeat concertSeat; - private BookingRequestDto bookingRequestDto; - private AuthUser authUser; - - @BeforeEach - void setUp() { - User user = userRepository.save(User.builder() - .email("test@example.com") - .nickname("test") - .point(100000000) - .roles(Set.of(UserRole.ROLE_USER)) - .build()); - - authUser = AuthUser.builder() - .id(user.getId()) - .email(user.getEmail()) - .roles(Set.of(UserRole.ROLE_USER)) - .build(); - - Stage stage = stageRepository.save(Stage.builder() - .stageName("테스트홀") - .stageStatus(StageStatus.AVAILABLE) - .totalSeat(0L) - .build()); - - Seat seat = seatRepository.save(Seat.builder() - .stage(stage) - .seatName("A-1") - .seatPrice(50L) - .build()); - - Concert concert = concertRepository.save(Concert.builder() - .user(user) - .stage(stage) - .title("테스트 공연") - .description("설명") - .startTime(LocalDateTime.now().plusDays(1)) - .ticketOpen(LocalDateTime.now().minusDays(1)) - .ticketClose(LocalDateTime.now().plusDays(1)) - .isSoldOut(false) - .build()); - - concertSeat = concertSeatRepository.save(ConcertSeat.builder() - .concert(concert) - .seat(seat) - .isReserved(false) - .build()); - - bookingRequestDto = new BookingRequestDto(List.of(concertSeat.getId())); - } + private final BookingRequestDto requestDto = new BookingRequestDto(List.of(1L, 2L)); @Test - void ConcurrencyProblem() throws InterruptedException { - int threadCount = 300; - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); - - List> results = new ArrayList<>(); - - for (int i = 0; i < threadCount; i++) { - results.add(executor.submit(() -> { - try { - bookingService.createBookingByLettuce(authUser, bookingRequestDto); - return "성공"; - } catch (Exception e) { - return "실패: " + e.getMessage(); - } finally { - latch.countDown(); - } - })); - } - - latch.await(); - - int successCount = 0; - int failureCount = 0; - - for (Future result : results) { - try { - String outcome = result.get(); - if (outcome.equals("성공")) - successCount++; - else - failureCount++; - System.out.println(outcome); - } catch (Exception e) { - e.printStackTrace(); - } - } - - System.out.println("성공한 요청 수: " + successCount); - System.out.println("실패한 요청 수: " + failureCount); - - assertThat(successCount).isLessThanOrEqualTo(1); + @DisplayName("예매 생성 - 기본 API 성공") + @WithMockUser(username = "user", roles = "USER") + void createBooking_success() throws Exception { + // given + given(bookingService.createBooking(any(), any())).willReturn(mockResponseDto); + + // when, then + mockMvc.perform(post("/bookings") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.message").value("예약 성공")) + .andExpect(jsonPath("$.result.userEmail").value("test@test.com")) + .andExpect(jsonPath("$.result.seatNames[0]").value("A1")); } + @Test + @DisplayName("예매 생성 - Redisson 락 API 성공") + @WithMockUser(username = "user", roles = "USER") + void createBooking_redisson_success() throws Exception { + // given + given(bookingService.createBookingByRedisson(any(), any())).willReturn(mockResponseDto); + + // when, then + mockMvc.perform(post("/bookings/redisson") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.message").value("예약 성공")) + .andExpect(jsonPath("$.result.userNickname").value("테스터")) + .andExpect(jsonPath("$.result.seatNames[1]").value("A2")); + } } diff --git a/src/test/java/nbc/ticketing/ticket911/domain/booking/service/BookingServiceTest.java b/src/test/java/nbc/ticketing/ticket911/domain/booking/service/BookingServiceTest.java new file mode 100644 index 0000000..3e51003 --- /dev/null +++ b/src/test/java/nbc/ticketing/ticket911/domain/booking/service/BookingServiceTest.java @@ -0,0 +1,139 @@ +package nbc.ticketing.ticket911.domain.booking.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Set; + +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 nbc.ticketing.ticket911.domain.auth.vo.AuthUser; +import nbc.ticketing.ticket911.domain.booking.application.BookingService; +import nbc.ticketing.ticket911.domain.booking.dto.request.BookingRequestDto; +import nbc.ticketing.ticket911.domain.booking.dto.response.BookingResponseDto; +import nbc.ticketing.ticket911.domain.booking.entity.Booking; +import nbc.ticketing.ticket911.domain.booking.exception.BookingException; +import nbc.ticketing.ticket911.domain.booking.exception.code.BookingExceptionCode; +import nbc.ticketing.ticket911.domain.booking.service.BookingDomainService; +import nbc.ticketing.ticket911.domain.concertseat.entity.ConcertSeat; +import nbc.ticketing.ticket911.domain.concertseat.service.ConcertSeatDomainService; +import nbc.ticketing.ticket911.domain.user.constant.UserRole; +import nbc.ticketing.ticket911.domain.user.entity.User; +import nbc.ticketing.ticket911.domain.user.service.UserDomainService; + +@ExtendWith(MockitoExtension.class) +class BookingServiceTest { + + @Mock + private UserDomainService userDomainService; + + @Mock + private BookingDomainService bookingDomainService; + + @Mock + private ConcertSeatDomainService concertSeatDomainService; + + @InjectMocks + private BookingService bookingService; + + private final User mockUser = User.builder() + .id(1L) + .email("test@test.com") + .password("pw") + .nickname("tester") + .point(10000) + .roles(Set.of(UserRole.ROLE_USER)) + .build(); + + private final List seatIds = List.of(1L, 2L); + private final BookingRequestDto bookingRequestDto = new BookingRequestDto(seatIds); + private final AuthUser authUser = AuthUser.builder() + .id(mockUser.getId()) + .email(mockUser.getEmail()) + .roles(mockUser.getRoles()) + .build(); + + private final ConcertSeat seat1 = mock(ConcertSeat.class); + private final ConcertSeat seat2 = mock(ConcertSeat.class); + private final List concertSeats = List.of(seat1, seat2); + + private final Booking booking = mock(Booking.class); + + @Nested + @DisplayName("예매 생성 테스트") + class CreateBookingTest { + + @Test + @DisplayName("성공적으로 예매 생성") + void success_createBooking() { + // Given + given(userDomainService.findActiveUserById(anyLong())).willReturn(mockUser); + given(concertSeatDomainService.findAllByIdOrThrow(anyList())).willReturn(concertSeats); + willDoNothing().given(bookingDomainService).validateBookable(anyList(), any()); + willDoNothing().given(concertSeatDomainService).validateAllSameConcert(anyList()); + willDoNothing().given(concertSeatDomainService).validateNotReserved(anyList()); + given(bookingDomainService.createBooking(any(), anyList())).willReturn(booking); + given(booking.getTotalPrice()).willReturn(5000); + willDoNothing().given(concertSeatDomainService).reserveAll(anyList()); + given(userDomainService.minusPoint(any(), anyInt())).willReturn(mockUser); + + // When + BookingResponseDto responseDto = bookingService.createBooking(authUser, bookingRequestDto); + + // Then + assertThat(responseDto).isNotNull(); + } + } + + @Nested + @DisplayName("예매 단건 조회 테스트") + class GetBookingTest { + + @Test + @DisplayName("예매 단건 조회 실패 - 소유자가 아님") + void fail_notOwnedBooking() { + // Given + User anotherUser = User.builder().id(99L).email("notowner@test.com").build(); + given(userDomainService.findActiveUserById(anyLong())).willReturn(anotherUser); + given(bookingDomainService.findBookingByIdOrElseThrow(anyLong())).willReturn(booking); + given(booking.isOwnedBy(any(User.class))).willReturn(false); + + // When & Then + BookingException exception = assertThrows(BookingException.class, + () -> bookingService.getBooking(authUser, 1L)); + + assertThat(exception.getErrorCode()).isEqualTo(BookingExceptionCode.NOT_OWN_BOOKING); + } + } + + @Nested + @DisplayName("예매 취소 테스트") + class CancelBookingTest { + + @Test + @DisplayName("예매 취소 실패 - 콘서트 시작됨") + void fail_concertStarted() { + // Given + given(userDomainService.findActiveUserById(anyLong())).willReturn(mockUser); + given(bookingDomainService.findBookingByIdOrElseThrow(anyLong())).willReturn(booking); + given(booking.isOwnedBy(any(User.class))).willReturn(true); + given(booking.isCanceled()).willReturn(false); + given(booking.validateCancellable(any())).willReturn(false); // 시작됨 + + // When & Then + BookingException exception = assertThrows(BookingException.class, + () -> bookingService.canceledBooking(authUser, 1L)); + + assertThat(exception.getErrorCode()).isEqualTo(BookingExceptionCode.CONCERT_STARTED_CANNOT_CANCEL); + } + } +} diff --git a/src/test/java/nbc/ticketing/ticket911/domain/lock/BookingConcurrencyTest.java b/src/test/java/nbc/ticketing/ticket911/domain/lock/BookingConcurrencyTest.java index ddc1724..8ef6529 100644 --- a/src/test/java/nbc/ticketing/ticket911/domain/lock/BookingConcurrencyTest.java +++ b/src/test/java/nbc/ticketing/ticket911/domain/lock/BookingConcurrencyTest.java @@ -15,14 +15,18 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; import lombok.extern.slf4j.Slf4j; +import nbc.ticketing.ticket911.common.aop.RedissonMultiLockAspect; import nbc.ticketing.ticket911.domain.auth.vo.AuthUser; import nbc.ticketing.ticket911.domain.booking.application.BookingService; import nbc.ticketing.ticket911.domain.booking.dto.request.BookingRequestDto; @@ -43,13 +47,16 @@ @Testcontainers @ActiveProfiles("test") @SpringBootTest +@Import(RedissonMultiLockAspect.class) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) public class BookingConcurrencyTest { @Container static GenericContainer redis = - new GenericContainer<>("redis:6.2-alpine") - .withExposedPorts(6379); + new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine")) + .withExposedPorts(6379) + .waitingFor(Wait.forListeningPort()) + .withStartupAttempts(5); @DynamicPropertySource static void redisProperties(DynamicPropertyRegistry reg) { @@ -82,8 +89,9 @@ static void redisProperties(DynamicPropertyRegistry reg) { private final int THREAD_COUNT = 10; @BeforeEach - void setUp() { + void setUp() throws InterruptedException { System.out.println(">>> bookingService class = " + bookingService.getClass().getName()); + Thread.sleep(3000); User user = userRepository.save(User.builder() .email("test@example.com") .nickname("test")