diff --git a/README.md b/README.md index b8beff153..2fe4d80e7 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,8 @@ curl -X GET http://localhost:8080/api/v1/movies - [캐싱 성능 테스트 보고서](https://gusty-football-62b.notion.site/17f81b29f03680718163fe0b7798383e) - [분산락 테스트 보고서](https://gusty-football-62b.notion.site/18781b29f03680049de7db34240a6733) +### jacoco 리포트 + +| movie-api | booking-api | application | infrastructure | domain | +|----------------------------| ----------- |----------------------------|----------------------------|----------------------------| + | ![j_m](etc/readme/j_m.png) | ![j_b](etc/readme/j_b.png) | ![j_a](etc/readme/j_a.png) | ![j_i](etc/readme/j_i.png) | ![j_d](etc/readme/j_d.png) | \ No newline at end of file diff --git a/application/build.gradle b/application/build.gradle index 21327fb32..4fa5b762a 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -2,13 +2,21 @@ dependencies { implementation project(':domain') testImplementation project(':infrastructure') - testRuntimeOnly 'com.h2database:h2' } bootJar { enabled = false } -tasks.named('test') { +test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } } \ No newline at end of file diff --git a/application/src/main/java/com/example/app/booking/service/CreateBookingService.java b/application/src/main/java/com/example/app/booking/service/CreateBookingService.java index 723317f25..522805fc9 100644 --- a/application/src/main/java/com/example/app/booking/service/CreateBookingService.java +++ b/application/src/main/java/com/example/app/booking/service/CreateBookingService.java @@ -16,9 +16,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.util.List; -import java.util.Set; - import static com.example.app.booking.exception.BookingErrorMessage.*; @Slf4j @@ -42,7 +39,7 @@ public Booking createBooking(String lockKey, CreateBookingCommand createBookingC checkValidUser(createBookingCommand.userId()); // 연속된 row 체크 - checkSeatsInSequence(createBookingCommand.seats()); + TheaterSeat.checkSeatsInSequence(createBookingCommand.seats()); // 기존 예약 조회 var existingBookingIds = loadBookingPort.loadAllBookings(createBookingCommand.toSearchBookingCommand()) @@ -63,14 +60,14 @@ public Booking createBooking(String lockKey, CreateBookingCommand createBookingC var requestSeats = loadSeatPort.loadAllSeats(createBookingCommand.toSearchSeatCommand()); // 요청한 자리 예약 가능 여부 체크 - checkSeatsAvailable(requestSeats); + Seat.checkSeatsAvailable(requestSeats); // 요청한 자리들 업데이트 var requestSeatIds = requestSeats.stream().map(Seat::id).toList(); updateSeatPort.updateAllSeats(requestSeatIds, booking.id()); return booking; - }, lockKey, 1L, 2L); + }, lockKey, 1L, 3L); } private void checkLimitMaxSeats(final int totalSeat) { @@ -79,23 +76,6 @@ private void checkLimitMaxSeats(final int totalSeat) { } } - private void checkSeatsAvailable(List seats) { - for (Seat seat : seats) { - if (seat.reserved()) { - throw new APIException(SEAT_ALREADY_OCCUPIED); - } - } - } - - private void checkSeatsInSequence(Set theaterSeats) { - String firstRow = TheaterSeat.getRow(theaterSeats.iterator().next()); - for (TheaterSeat theaterSeat : theaterSeats) { - if (!TheaterSeat.getRow(theaterSeat).equals(firstRow)) { - throw new APIException(SEAT_ROW_NOT_IN_SEQUENCE); - } - } - } - private void checkValidUser(final long userId) { log.info(">>>>>> Checking userId : {}", userId); /* pseudo code diff --git a/application/src/test/java/com/example/app/booking/service/CreateBookingServiceTest.java b/application/src/test/java/com/example/app/booking/service/CreateBookingServiceTest.java new file mode 100644 index 000000000..1aed96998 --- /dev/null +++ b/application/src/test/java/com/example/app/booking/service/CreateBookingServiceTest.java @@ -0,0 +1,110 @@ +package com.example.app.booking.service; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.dto.CreateBookingCommand; +import com.example.app.booking.out.persistence.adapter.BookingPersistenceAdapter; +import com.example.app.booking.out.persistence.adapter.SeatPersistenceAdapter; +import com.example.app.common.exception.APIException; +import com.example.app.common.function.DistributedLockService; +import com.example.app.config.EmbeddedRedisConfig; +import com.example.app.movie.type.TheaterSeat; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.Set; +import java.util.function.Supplier; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class CreateBookingServiceTest { + + private FixtureMonkey fixtureMonkey; + + @Mock + private DistributedLockService distributedLockService; + + @Mock + private BookingPersistenceAdapter bookingPersistenceAdapter; + + @Mock + private SeatPersistenceAdapter seatPersistenceAdapter; + + @InjectMocks + private CreateBookingService sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void 예약_테스트() { + var key = fixtureMonkey.giveMeOne(String.class); + var continuousSeats = Set.of(TheaterSeat.A3, TheaterSeat.A4, TheaterSeat.A5); + var bookingCommand = fixtureMonkey.giveMeBuilder(CreateBookingCommand.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(Set.class, "seats")) + .set("seats", continuousSeats) + .sample(); + + var booking = fixtureMonkey.giveMeBuilder(Booking.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(int.class, "totalSeats") + .parameter(LocalDate.class)) + .set("totalSeats", 3) + .sample(); + + when(distributedLockService.executeWithLockAndReturn(any(Supplier.class), any(String.class), any(Long.class), any(Long.class))) + .thenReturn(booking); + + var result = sut.createBooking(key, bookingCommand); + + assertEquals(booking, result); + } + + @Test + public void 예약_불가_테스트() { + var key = fixtureMonkey.giveMeOne(String.class); + var discontinuousSeats = Set.of(TheaterSeat.B1, TheaterSeat.C1, TheaterSeat.D1); + var bookingCommand = fixtureMonkey.giveMeBuilder(CreateBookingCommand.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(Set.class, "seats")) + .set("seats", discontinuousSeats) + .sample(); + + var exception = assertThrows(APIException.class, () -> sut.createBooking(key, bookingCommand)); + assertEquals(SEAT_ROW_NOT_IN_SEQUENCE.getMessage(), exception.getMessage()); + } +} diff --git a/application/src/test/java/com/example/app/booking/usecase/CreateBookingUseCaseTest.java b/application/src/test/java/com/example/app/booking/usecase/CreateBookingUseCaseTest.java deleted file mode 100644 index 60ebc9a73..000000000 --- a/application/src/test/java/com/example/app/booking/usecase/CreateBookingUseCaseTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.example.app.booking.usecase; - -import com.example.app.booking.dto.CreateBookingCommand; -import com.example.app.booking.dto.SearchBookingCommand; -import com.example.app.booking.out.persistence.adapter.BookingPersistenceAdapter; -import com.example.app.booking.out.persistence.adapter.SeatPersistenceAdapter; -import com.example.app.config.QuerydslConfig; -import com.example.app.movie.type.TheaterSeat; -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.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.jdbc.Sql; - -import java.time.LocalDate; -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 static org.junit.jupiter.api.Assertions.assertEquals; - -@DisplayName("예약 생성 유즈케이스 테스트") -@SpringBootTest -@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") -@Import({QuerydslConfig.class, SeatPersistenceAdapter.class, BookingPersistenceAdapter.class}) -@Sql(scripts = "/seat-data.sql") -public class CreateBookingUseCaseTest { - - @Autowired - private CreateBookingUseCase createBookingUseCase; - - @Autowired - private BookingPersistenceAdapter bookingPersistenceAdapter; - - private final List users = new ArrayList<>(); - - @BeforeEach - public void setUp() { - var user1Command = CreateBookingCommand.builder() - .userId(1L) - .movieId(2L) - .showtimeId(5L) - .theaterId(1L) - .bookingDate(LocalDate.of(2025, 3, 1)) - .seats(Set.of(TheaterSeat.A3, TheaterSeat.A4, TheaterSeat.A5)) - .build(); - - var user2Command = CreateBookingCommand.builder() - .userId(2L) - .movieId(2L) - .showtimeId(5L) - .theaterId(1L) - .bookingDate(LocalDate.of(2025, 3, 1)) - .seats(Set.of(TheaterSeat.A3, TheaterSeat.A4, TheaterSeat.A5)) - .build(); - - var user3Command = CreateBookingCommand.builder() - .userId(3L) - .movieId(2L) - .showtimeId(5L) - .theaterId(1L) - .bookingDate(LocalDate.of(2025, 3, 1)) - .seats(Set.of(TheaterSeat.A3, TheaterSeat.A4, TheaterSeat.A5)) - .build(); - - users.add(user1Command); - users.add(user2Command); - users.add(user3Command); - } - - @Test - public void 동시성_예약_테스트() throws InterruptedException { - int threadCount = users.size(); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); // pool 생성 - CountDownLatch latch = new CountDownLatch(threadCount); // 쓰레드 작업 카운트 - - for (int i = 0; i < threadCount; i++) { - final int taskId = i; - executor.execute(() -> { - try { - createBookingUseCase.createBooking("BOOKING:2:5:1:2025-03-01", users.get(taskId)); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); // 카운트 0까지 기다림 - executor.shutdown(); // pool 종료 - - var command = SearchBookingCommand.builder() - .movieId(2L) - .showtimeId(5L) - .theaterId(1L) - .bookingDate(LocalDate.of(2025, 3, 1)) - .build(); - - var bookings = bookingPersistenceAdapter.loadAllBookings(command); - - assertEquals(1, bookings.size(), "예약은 하나만 성공"); - } -} diff --git a/application/src/test/java/com/example/app/config/EmbeddedRedisConfig.java b/application/src/test/java/com/example/app/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..8c80142a9 --- /dev/null +++ b/application/src/test/java/com/example/app/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} \ No newline at end of file diff --git a/application/src/test/java/com/example/app/movie/service/SearchMovieServiceTest.java b/application/src/test/java/com/example/app/movie/service/SearchMovieServiceTest.java new file mode 100644 index 000000000..e86d3eb3d --- /dev/null +++ b/application/src/test/java/com/example/app/movie/service/SearchMovieServiceTest.java @@ -0,0 +1,82 @@ +package com.example.app.movie.service; + +import com.example.app.config.EmbeddedRedisConfig; +import com.example.app.movie.domain.Movie; +import com.example.app.movie.domain.Showtime; +import com.example.app.movie.domain.Theater; +import com.example.app.movie.dto.SearchMovieCommand; +import com.example.app.movie.out.persistence.adapter.MoviePersistenceAdapter; +import com.example.app.movie.type.MovieGenre; +import com.example.app.movie.type.MovieRating; +import com.example.app.movie.type.MovieStatus; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.api.type.TypeReference; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.List; + +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class SearchMovieServiceTest { + + private final int TOTAL_MOVIES = 10; + + private FixtureMonkey fixtureMonkey; + + @Mock + private MoviePersistenceAdapter moviePersistenceAdapter; + + @InjectMocks + private SearchMovieService sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void 영화_검색() { + var searchCommand = fixtureMonkey.giveMeBuilder(SearchMovieCommand.class) + .instantiate(constructor() + .parameter(String.class) + .parameter(MovieGenre.class)) + .sample(); + + var movies = fixtureMonkey.giveMeBuilder(Movie.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(String.class) + .parameter(String.class) + .parameter(MovieStatus.class) + .parameter(MovieRating.class) + .parameter(MovieGenre.class) + .parameter(String.class) + .parameter(int.class) + .parameter(LocalDate.class) + .parameter(new TypeReference>(){}) + .parameter(new TypeReference>(){})) + .sampleList(TOTAL_MOVIES); + + when(moviePersistenceAdapter.loadAllMovies(any(SearchMovieCommand.class))).thenReturn(movies); + + var result = sut.searchMovies(searchCommand); + + assertEquals(TOTAL_MOVIES, result.size()); + } +} diff --git a/application/src/test/resources/movie-data.sql b/application/src/test/resources/movie-data.sql new file mode 100644 index 000000000..fb8112d39 --- /dev/null +++ b/application/src/test/resources/movie-data.sql @@ -0,0 +1,36 @@ +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (1,'나 홀로 집에','크리스마스 휴가 당일, 늦잠을 자 정신없이 공항으로 출발한 맥콜리스터 가족은 전날 부린 말썽에 대한 벌로 다락방에 들어가 있던 8살 케빈을 깜박 잊고 프랑스로 떠나버린다. 매일 형제들에게 치이며 가족이 전부 없어졌으면 좋겠다고 생각한 케빈은 갑자기 찾아온 자유를 만끽한다.','SHOWING','ALL_AGES','COMEDY','https://m.media-amazon.com/images/M/MV5BNzNmNmQ2ZDEtMTc1MS00NjNiLThlMGUtZmQxNTg1Nzg5NWMzXkEyXkFqcGc@._V1_QL75_UX190_CR0,1,190,281_.jpg',103,'1991-07-06','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (2,'탑건','최고의 파일럿들만이 갈 수 있는 캘리포니아의 한 비행 조종 학교 탑건에서의 사나이들의 우정과 사랑의 모험이 시작된다. 자신을 좇는 과거의 기억과 경쟁자, 그리고 사랑 사이에서 고군분투하는 그의 여정이 펼쳐진다.','SHOWING','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BZmVjNzQ3MjYtYTZiNC00Y2YzLWExZTEtMTM2ZDllNDI0MzgyXkEyXkFqcGc@._V1_QL75_UX190_CR0,6,190,281_.jpg',109,'1986-05-12','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (3,'탑건:메버릭','해군 최고의 비행사 피트 미첼은 비행 훈련소에서 갓 졸업을 한 신입 비행사들 팀의 훈련을 맡게 된다. 자신을 좇는 과거의 기억과 위험천만한 임무 속에서 고군분투하는 그의 비상이 펼쳐진다.','SHOWING','TWELVE_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BMDBkZDNjMWEtOTdmMi00NmExLTg5MmMtNTFlYTJlNWY5YTdmXkEyXkFqcGc@._V1_QL75_UX190_CR0,0,190,281_.jpg',130,'2022-05-18','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (4,'하얼빈','1908년 함경북도 신아산에서 안중근이 이끄는 독립군들은 일본군과의 전투에서 큰 승리를 거둔다. 대한의군 참모중장 안중근은 만국공법에 따라 전쟁포로인 일본인들을 풀어주게 되고, 이 사건으로 인해 독립군 사이에서는 안중근에 대한 의심과 함께 균열이 일기 시작한다.','SCHEDULED','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BNmY4YzM5NzUtMTg4Yy00Yzc3LThlZDktODk4YjljZmNlODA0XkEyXkFqcGc@._V1_QL75_UY281_CR4,0,190,281_.jpg',113,'2024-12-24','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (1,1,'08:00:00','09:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (2,1,'10:00:00','11:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (3,1,'13:00:00','14:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (4,1,'15:30:00','17:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (5,2,'10:30:00','12:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (6,2,'14:30:00','16:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (7,3,'11:30:00','14:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (8,3,'15:40:00','17:25:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (9,3,'18:50:00','20:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (10,3,'07:30:00','09:50:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (11,4,'11:10:00','13:05:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (1,'강남점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (2,'강북점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (3,'봉천점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (4,'안양점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (5,'평촌점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (6,'인덕원점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (7,'사당점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (8,'삼성점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (9,'신림점','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (1,1,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (2,2,3,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (3,3,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (4,1,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (5,1,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (6,2,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (7,2,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (8,3,2,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (9,4,8,'2025-01-09 00:00:00','2025-01-09 00:00:00'); \ No newline at end of file diff --git a/booking-api/build.gradle b/booking-api/build.gradle index b548e1161..1f169b1af 100644 --- a/booking-api/build.gradle +++ b/booking-api/build.gradle @@ -8,6 +8,29 @@ bootJar { enabled = true } -tasks.named('test') { +test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} + +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + + violationRules { + rule { + element = 'PACKAGE' + includes = ['com.example.app.booking.presentation.controller.*'] + limit { + maximum = 0.80 + } + } + } } \ No newline at end of file diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java b/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java index 7119b0953..d9d877cfc 100644 --- a/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java +++ b/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java @@ -2,6 +2,8 @@ import com.example.app.booking.domain.Booking; import com.example.app.booking.presentation.dto.request.CreateBookingRequest; +import com.example.app.booking.presentation.service.RedisRateLimitService; +import com.example.app.booking.presentation.util.BookingKeyGenerator; import com.example.app.booking.usecase.CreateBookingUseCase; import com.example.app.booking.usecase.SendMessageUseCase; import jakarta.validation.Valid; @@ -13,8 +15,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.time.format.DateTimeFormatter; - @RestController @RequiredArgsConstructor @RequestMapping("/v1") @@ -22,18 +22,19 @@ public class BookingController { private final CreateBookingUseCase createBookingUseCase; private final SendMessageUseCase sendMessageUseCase; + private final RedisRateLimitService redisRateLimitService; @PostMapping("/booking") public ResponseEntity createBooking(@Valid @RequestBody CreateBookingRequest createBookingRequest) throws InterruptedException { - var lockKey = String.format("BOOKING:%d:%d:%d:%s", - createBookingRequest.movieId(), - createBookingRequest.showtimeId(), - createBookingRequest.theaterId(), - createBookingRequest.bookingDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - createBookingRequest.seats().getFirst().charAt(0)); + redisRateLimitService.checkAccessLimit(BookingKeyGenerator.generateRateLimitKey(createBookingRequest)); + + var lockKey = BookingKeyGenerator.generateLockKey(createBookingRequest); var booking = createBookingUseCase.createBooking(lockKey, createBookingRequest.toCreateBookingCommand()); sendMessageUseCase.sendMessage(String.format("BookingId : %d, UserId : %d", booking.id(), booking.userId())); + + redisRateLimitService.setAccessLimit(BookingKeyGenerator.generateRateLimitKey(createBookingRequest)); + return ResponseEntity.status(HttpStatus.CREATED).build(); } } diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/service/RateLimitService.java b/booking-api/src/main/java/com/example/app/booking/presentation/service/RateLimitService.java new file mode 100644 index 000000000..e35efe82e --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/service/RateLimitService.java @@ -0,0 +1,37 @@ +package com.example.app.booking.presentation.service; + +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; +import com.example.app.common.exception.RateLimitException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.RateLimiter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +@SuppressWarnings("UnstableApiUsage") +@Service +@RequiredArgsConstructor +public class RateLimitService { + private final Cache rateLimiters = CacheBuilder.newBuilder() + .expireAfterAccess(5, TimeUnit.MINUTES) + .build(); + + public void checkAccessLimit(CreateBookingRequest createBookingRequest) throws ExecutionException { + var key = String.format("%d:%d:%d:%d:%s", + createBookingRequest.userId(), + createBookingRequest.movieId(), + createBookingRequest.showtimeId(), + createBookingRequest.theaterId(), + createBookingRequest.bookingDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + + RateLimiter rateLimiter = rateLimiters.get(key, () -> RateLimiter.create(1.0/300.0)); + + if (!rateLimiter.tryAcquire()) { + throw new RateLimitException(); + } + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/service/RedisRateLimitService.java b/booking-api/src/main/java/com/example/app/booking/presentation/service/RedisRateLimitService.java new file mode 100644 index 000000000..83c289f17 --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/service/RedisRateLimitService.java @@ -0,0 +1,58 @@ +package com.example.app.booking.presentation.service; + +import com.example.app.common.exception.RateLimitException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class RedisRateLimitService { + + private final RedisTemplate redisTemplate; + + public void checkAccessLimit(String key) { + Boolean allowed = redisTemplate.execute(new DefaultRedisScript() { + { + setScriptText(""" + local key = KEYS[1] + local now = redis.call('TIME') + local timestamp = tonumber(now[1]) + + if redis.call('GET', key) then + return 0 + end + + return 1 + """); + setResultType(Boolean.class); + } + }, Collections.singletonList(key)); + + if (Boolean.FALSE.equals(allowed)) { + throw new RateLimitException(); + } + } + + public void setAccessLimit(String key) { + redisTemplate.execute(new DefaultRedisScript() { + { + setScriptText(""" + local key = KEYS[1] + local now = redis.call('TIME') + local timestamp = tonumber(now[1]) + + if redis.call('GET', key) then + return + end + + redis.call('SET', key, timestamp, 'EX', 300) -- 5분 만료 + """); + setResultType(Boolean.class); + } + }, Collections.singletonList(key)); + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/util/BookingKeyGenerator.java b/booking-api/src/main/java/com/example/app/booking/presentation/util/BookingKeyGenerator.java new file mode 100644 index 000000000..e203fe563 --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/util/BookingKeyGenerator.java @@ -0,0 +1,28 @@ +package com.example.app.booking.presentation.util; + +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; + +import java.time.format.DateTimeFormatter; + +public class BookingKeyGenerator { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public static String generateLockKey(CreateBookingRequest createBookingRequest) { + return String.format("BOOKING:%d:%d:%d:%s:%c", + createBookingRequest.movieId(), + createBookingRequest.showtimeId(), + createBookingRequest.theaterId(), + createBookingRequest.bookingDate().format(DATE_FORMATTER), + createBookingRequest.seats().getFirst().charAt(0)); + } + + public static String generateRateLimitKey(CreateBookingRequest createBookingRequest) { + return String.format("BOOKING:RATE_LIMIT:%d:%d:%d:%d:%s", + createBookingRequest.userId(), + createBookingRequest.movieId(), + createBookingRequest.showtimeId(), + createBookingRequest.theaterId(), + createBookingRequest.bookingDate().format(DATE_FORMATTER)); + } +} diff --git a/booking-api/src/test/java/com/example/BookingApiApplicationTests.java b/booking-api/src/test/java/com/example/BookingApiApplicationTests.java new file mode 100644 index 000000000..327e200ca --- /dev/null +++ b/booking-api/src/test/java/com/example/BookingApiApplicationTests.java @@ -0,0 +1,7 @@ +package com.example; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class BookingApiApplicationTests { +} diff --git a/booking-api/src/test/java/com/example/app/booking/presentation/config/EmbeddedRedisConfig.java b/booking-api/src/test/java/com/example/app/booking/presentation/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..ddeb08b0b --- /dev/null +++ b/booking-api/src/test/java/com/example/app/booking/presentation/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.booking.presentation.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} \ No newline at end of file diff --git a/booking-api/src/test/java/com/example/app/booking/presentation/controller/BookingControllerTest.java b/booking-api/src/test/java/com/example/app/booking/presentation/controller/BookingControllerTest.java new file mode 100644 index 000000000..c8046765e --- /dev/null +++ b/booking-api/src/test/java/com/example/app/booking/presentation/controller/BookingControllerTest.java @@ -0,0 +1,97 @@ +package com.example.app.booking.presentation.controller; + +import com.example.app.booking.presentation.config.EmbeddedRedisConfig; +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; +import com.example.app.common.exception.APIException; +import com.example.app.common.exception.LockException; +import com.example.app.common.exception.RateLimitException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +@Sql(scripts = "/booking-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +public class BookingControllerTest { + + private final int TOTAL_BOOKINGS = 10; + private final int SUCCESS_BOOKING = 1; + private final int FAIL_BOOKINGS = 9; + + @Autowired + private BookingController sut; + + @Test + public void 영화_예약_성공_테스트() throws InterruptedException { + var bookingRequest = new CreateBookingRequest(1L, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("A1", "A2")); + var response = sut.createBooking(bookingRequest); + assertEquals(201, response.getStatusCode().value()); + } + + @Test + public void Rate_Limit_유저_영화_시간표_상영관_per_1요청_5분_테스트() { + var bookingRequest = new CreateBookingRequest(1L, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("C1", "C2")); + assertThrows(RateLimitException.class, () -> sut.createBooking(bookingRequest)); + } + + @Test + public void 영화_예약_실패_테스트() { + var bookingRequest = new CreateBookingRequest(2L, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("A1", "B1")); + var exception = assertThrows(APIException.class, () -> sut.createBooking(bookingRequest)); + assertEquals(SEAT_ROW_NOT_IN_SEQUENCE.getMessage(), exception.getMessage()); + } + + @Test + public void 동시성_예약_테스트() throws InterruptedException { + List bookingRequests = new ArrayList<>(); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger exceptionCount = new AtomicInteger(0); + + + for (int i=0; i < TOTAL_BOOKINGS; i++) { + bookingRequests.add(new CreateBookingRequest((long) i+5, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("E1", "E2"))); + } + + int threadCount = bookingRequests.size(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); // pool 생성 + CountDownLatch latch = new CountDownLatch(threadCount); // 쓰레드 작업 카운트 + + for (int i = 0; i < threadCount; i++) { + final int taskId = i; + + executor.execute(() -> { + try { + sut.createBooking(bookingRequests.get(taskId)); + successCount.incrementAndGet(); + } catch (LockException | APIException e) { + exceptionCount.incrementAndGet(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 카운트 0까지 기다림 + executor.shutdown(); // pool 종료 + + assertEquals(SUCCESS_BOOKING, successCount.get()); + assertEquals(FAIL_BOOKINGS, exceptionCount.get()); + } +} diff --git a/booking-api/src/test/resources/application-test.yml b/booking-api/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/booking-api/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/booking-api/src/test/resources/booking-data.sql b/booking-api/src/test/resources/booking-data.sql new file mode 100644 index 000000000..bfc22135b --- /dev/null +++ b/booking-api/src/test/resources/booking-data.sql @@ -0,0 +1,25 @@ +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A1',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A2',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A3',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A4',0,0,'2025-01-26 00:45:45','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A5',0,0,'2025-01-26 00:45:45','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); \ No newline at end of file diff --git a/build.gradle b/build.gradle index 466096b8a..d20bb2929 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.2' id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' } java { @@ -17,12 +18,23 @@ allprojects { repositories { mavenCentral() } + + jacoco { + toolVersion = '0.8.5' + } + + tasks.withType(Test) { + jacoco { + enabled = true + } + } } subprojects { apply plugin: 'java' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -33,12 +45,18 @@ subprojects { implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'org.redisson:redisson-spring-boot-starter:3.41.0' + implementation 'com.google.guava:guava:33.4.0-jre' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-starter:1.1.8' + testImplementation 'com.github.codemonstur:embedded-redis:1.0.0' // MacOS Sonoma + // testImplementation 'it.ozimov:embedded-redis:0.7.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' } } \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle index 74e3a36c9..af7ac9deb 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -6,6 +6,15 @@ bootJar { enabled = false } -tasks.named('test') { +test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } } \ No newline at end of file diff --git a/domain/src/main/java/com/example/app/booking/domain/Seat.java b/domain/src/main/java/com/example/app/booking/domain/Seat.java index 426e1e853..be2b745be 100644 --- a/domain/src/main/java/com/example/app/booking/domain/Seat.java +++ b/domain/src/main/java/com/example/app/booking/domain/Seat.java @@ -1,9 +1,13 @@ package com.example.app.booking.domain; +import com.example.app.common.exception.APIException; import com.example.app.movie.type.TheaterSeat; import lombok.Builder; import java.time.LocalDate; +import java.util.List; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ALREADY_OCCUPIED; @Builder public record Seat( @@ -16,4 +20,12 @@ public record Seat( TheaterSeat theaterSeat, boolean reserved ) { + + public static void checkSeatsAvailable(List seats) { + for (Seat seat : seats) { + if (seat.reserved()) { + throw new APIException(SEAT_ALREADY_OCCUPIED); + } + } + } } diff --git a/domain/src/main/java/com/example/app/common/annotation/ClientIp.java b/domain/src/main/java/com/example/app/common/annotation/ClientIp.java new file mode 100644 index 000000000..61ae4d172 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/ClientIp.java @@ -0,0 +1,11 @@ +package com.example.app.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface ClientIp { +} diff --git a/domain/src/main/java/com/example/app/common/annotation/ClientIpArgumentResolver.java b/domain/src/main/java/com/example/app/common/annotation/ClientIpArgumentResolver.java new file mode 100644 index 000000000..abd473845 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/ClientIpArgumentResolver.java @@ -0,0 +1,45 @@ +package com.example.app.common.annotation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Objects; +import java.util.stream.Stream; + +@Component +public class ClientIpArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter methodParameter) { + return methodParameter.hasParameterAnnotation(ClientIp.class); + } + + @Override + public String resolveArgument( + MethodParameter methodParameter, + ModelAndViewContainer modelAndViewContainer, + NativeWebRequest nativeWebRequest, + WebDataBinderFactory webDataBinderFactory) { + HttpServletRequest request = (HttpServletRequest) nativeWebRequest.getNativeRequest(); + + return getClientIp(request); + } + + private String getClientIp(HttpServletRequest request) { + return Stream.of( + request.getHeader("X-Forwarded-For"), + request.getHeader("Proxy-Client-IP"), + request.getHeader("WL-Proxy-Client-IP"), + request.getHeader("HTTP_CLIENT_IP"), + request.getHeader("HTTP_X_FORWARDED_FOR") + ) + .filter(Objects::nonNull) + .findFirst() + .orElse(request.getRemoteAddr()); + } +} diff --git a/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java b/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java index cdf9c5f8a..db664dbbb 100644 --- a/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java +++ b/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java @@ -30,6 +30,11 @@ public ResponseEntity handleLockException(LockException ex) { return new ResponseEntity<>(new ErrorMessage(ex.getHttpStatus().name(), ex.getMessage()), ex.getHttpStatus()); } + @ExceptionHandler(RateLimitException.class) + public ResponseEntity handleRateLimitException(RateLimitException ex) { + return new ResponseEntity<>(new ErrorMessage(ex.getHttpStatus().name(), ex.getMessage()), ex.getHttpStatus()); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException(RuntimeException ex) { return new ResponseEntity<>( diff --git a/domain/src/main/java/com/example/app/common/exception/RateLimitException.java b/domain/src/main/java/com/example/app/common/exception/RateLimitException.java new file mode 100644 index 000000000..2c6c90320 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/exception/RateLimitException.java @@ -0,0 +1,15 @@ +package com.example.app.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class RateLimitException extends RuntimeException { + + private final HttpStatus httpStatus; + + public RateLimitException() { + super("요청이 너무 많아요"); + this.httpStatus = HttpStatus.TOO_MANY_REQUESTS; + } +} diff --git a/domain/src/main/java/com/example/app/config/WebMvcConfig.java b/domain/src/main/java/com/example/app/config/WebMvcConfig.java new file mode 100644 index 000000000..0ad8d8a56 --- /dev/null +++ b/domain/src/main/java/com/example/app/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.example.app.config; + +import com.example.app.common.annotation.ClientIpArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final ClientIpArgumentResolver clientIpArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(clientIpArgumentResolver); + } +} diff --git a/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java b/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java index 77b423b9f..1b22d287a 100644 --- a/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java +++ b/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java @@ -1,8 +1,13 @@ package com.example.app.movie.type; +import com.example.app.common.exception.APIException; import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.Set; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; + @Getter @RequiredArgsConstructor public enum TheaterSeat { @@ -12,7 +17,16 @@ public enum TheaterSeat { D1,D2,D3,D4,D5, E1,E2,E3,E4,E5; - public static String getRow(TheaterSeat theaterSeat) { - return theaterSeat.name().substring(0, 1); + public String getRow() { + return this.name().substring(0, 1); + } + + public static void checkSeatsInSequence(Set theaterSeats) { + String firstRow = theaterSeats.iterator().next().getRow(); + for (TheaterSeat theaterSeat : theaterSeats) { + if (!theaterSeat.getRow().equals(firstRow)) { + throw new APIException(SEAT_ROW_NOT_IN_SEQUENCE); + } + } } } diff --git a/domain/src/test/java/com/example/TestApplication.java b/domain/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..e0ed55e30 --- /dev/null +++ b/domain/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class TestApplication { +} diff --git a/domain/src/test/java/com/example/app/booking/domain/SeatTest.java b/domain/src/test/java/com/example/app/booking/domain/SeatTest.java new file mode 100644 index 000000000..ddf89372f --- /dev/null +++ b/domain/src/test/java/com/example/app/booking/domain/SeatTest.java @@ -0,0 +1,30 @@ +package com.example.app.booking.domain; + +import com.example.app.common.exception.APIException; +import com.example.app.config.EmbeddedRedisConfig; +import com.example.app.movie.type.TheaterSeat; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ALREADY_OCCUPIED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class SeatTest { + + @Test + public void 예약_가능_자리_여부_테스트() { + var hasReservedSeats = List.of( + Seat.builder().theaterSeat(TheaterSeat.A1).reserved(false).build(), + Seat.builder().theaterSeat(TheaterSeat.A2).reserved(true).build(), + Seat.builder().theaterSeat(TheaterSeat.A3).reserved(false).build()); + + var exception = assertThrows(APIException.class, () -> Seat.checkSeatsAvailable(hasReservedSeats)); + assertEquals(SEAT_ALREADY_OCCUPIED.getMessage(), exception.getMessage()); + } +} diff --git a/domain/src/test/java/com/example/app/config/EmbeddedRedisConfig.java b/domain/src/test/java/com/example/app/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..368d74cc5 --- /dev/null +++ b/domain/src/test/java/com/example/app/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} diff --git a/domain/src/test/java/com/example/app/movie/type/TheaterSeatTest.java b/domain/src/test/java/com/example/app/movie/type/TheaterSeatTest.java new file mode 100644 index 000000000..0e8190613 --- /dev/null +++ b/domain/src/test/java/com/example/app/movie/type/TheaterSeatTest.java @@ -0,0 +1,34 @@ +package com.example.app.movie.type; + +import com.example.app.common.exception.APIException; +import com.example.app.config.EmbeddedRedisConfig; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.util.Set; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class TheaterSeatTest { + + @Test + public void 열_조회_테스트() { + assertEquals("A", TheaterSeat.A1.getRow()); + assertEquals("B", TheaterSeat.B1.getRow()); + assertEquals("C", TheaterSeat.C1.getRow()); + assertEquals("D", TheaterSeat.D1.getRow()); + assertEquals("E", TheaterSeat.E1.getRow()); + } + + @Test + public void 연속된_열_체크_테스트() { + var discontinuousSeats = Set.of(TheaterSeat.B1, TheaterSeat.C1, TheaterSeat.D1); + var exception = assertThrows(APIException.class, () -> TheaterSeat.checkSeatsInSequence(discontinuousSeats)); + assertEquals(SEAT_ROW_NOT_IN_SEQUENCE.getMessage(), exception.getMessage()); + } +} diff --git a/domain/src/test/resources/application-test.yml b/domain/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/domain/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/etc/http/booking.http b/etc/http/booking.http index 74db9eb0d..650e211aa 100644 --- a/etc/http/booking.http +++ b/etc/http/booking.http @@ -19,5 +19,5 @@ Content-Type: application/json "showtimeId": 5, "theaterId": 1, "bookingDate": "2025-03-01", - "seats": ["A3", "A4"] + "seats": ["B1", "B2"] } \ No newline at end of file diff --git a/etc/readme/j_a.png b/etc/readme/j_a.png new file mode 100644 index 000000000..add65f215 Binary files /dev/null and b/etc/readme/j_a.png differ diff --git a/etc/readme/j_b.png b/etc/readme/j_b.png new file mode 100644 index 000000000..4588c174b Binary files /dev/null and b/etc/readme/j_b.png differ diff --git a/etc/readme/j_d.png b/etc/readme/j_d.png new file mode 100644 index 000000000..5cb82a9cd Binary files /dev/null and b/etc/readme/j_d.png differ diff --git a/etc/readme/j_i.png b/etc/readme/j_i.png new file mode 100644 index 000000000..69937f624 Binary files /dev/null and b/etc/readme/j_i.png differ diff --git a/etc/readme/j_m.png b/etc/readme/j_m.png new file mode 100644 index 000000000..c1d0773db Binary files /dev/null and b/etc/readme/j_m.png differ diff --git a/infrastructure/build.gradle b/infrastructure/build.gradle index 8b2574ada..c39a073e6 100644 --- a/infrastructure/build.gradle +++ b/infrastructure/build.gradle @@ -31,6 +31,15 @@ bootJar { enabled = false } -tasks.named('test') { +test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } } \ No newline at end of file diff --git a/infrastructure/src/main/java/com/example/app/config/RedisConfig.java b/infrastructure/src/main/java/com/example/app/config/RedisConfig.java new file mode 100644 index 000000000..b948adb7c --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/config/RedisConfig.java @@ -0,0 +1,20 @@ +package com.example.app.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } +} diff --git a/infrastructure/src/test/java/com/example/TestApplication.java b/infrastructure/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..e0ed55e30 --- /dev/null +++ b/infrastructure/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class TestApplication { +} diff --git a/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/BookingRepositoryTest.java b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/BookingRepositoryTest.java new file mode 100644 index 000000000..1851e3280 --- /dev/null +++ b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/BookingRepositoryTest.java @@ -0,0 +1,73 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.BookingEntity; +import com.example.app.config.QuerydslConfig; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import com.querydsl.core.types.ExpressionUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.stream.Stream; + +import static com.example.app.booking.out.persistence.entity.QBookingEntity.bookingEntity; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import({QuerydslConfig.class}) +public class BookingRepositoryTest { + + private FixtureMonkey fixtureMonkey; + + @Autowired + private BookingRepository sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void save_findAllBy_테스트() { + var booking1 = fixtureMonkey.giveMeBuilder(BookingEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class, "movieId") + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(long.class) + .parameter(int.class)) + .set("movieId", 1L) + .sampleList(3); + + var booking2 = fixtureMonkey.giveMeBuilder(BookingEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class, "movieId") + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(long.class) + .parameter(int.class)) + .set("movieId", 2L) + .sampleList(4); + + sut.saveAll(Stream.concat(booking1.stream(), booking2.stream()).toList()); + + var predicate = ExpressionUtils.allOf(bookingEntity.movieId.eq(1L)); + + var result = sut.findAllBy(predicate); + + assertEquals(3, result.size()); + } +} diff --git a/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/SeatRepositoryTest.java b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/SeatRepositoryTest.java new file mode 100644 index 000000000..b178bfa1a --- /dev/null +++ b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/SeatRepositoryTest.java @@ -0,0 +1,65 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.SeatEntity; +import com.example.app.config.QuerydslConfig; +import com.example.app.movie.type.TheaterSeat; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import com.querydsl.core.types.ExpressionUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.HashSet; + +import static com.example.app.booking.out.persistence.entity.QSeatEntity.seatEntity; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import({QuerydslConfig.class}) +public class SeatRepositoryTest { + + private FixtureMonkey fixtureMonkey; + + @Autowired + private SeatRepository sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void save_findAllBy_테스트() { + var reservedSeats = new HashSet<>(fixtureMonkey.giveMeBuilder(SeatEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(TheaterSeat.class) + .parameter(boolean.class, "reserved") + .parameter(long.class, "version")) + .set("reserved", true) + .set("version", 1L) + .sampleList(10)); + + sut.saveAll(reservedSeats); + + var predicate = ExpressionUtils.allOf(seatEntity.reserved.isTrue()); + + var result = sut.findAllBy(predicate); + + assertEquals(10, result.size()); + } +} diff --git a/infrastructure/src/test/java/com/example/app/movie/out/persistence/repository/MovieRepositoryTest.java b/infrastructure/src/test/java/com/example/app/movie/out/persistence/repository/MovieRepositoryTest.java new file mode 100644 index 000000000..84b28ad2b --- /dev/null +++ b/infrastructure/src/test/java/com/example/app/movie/out/persistence/repository/MovieRepositoryTest.java @@ -0,0 +1,93 @@ +package com.example.app.movie.out.persistence.repository; + +import com.example.app.config.QuerydslConfig; +import com.example.app.movie.out.persistence.entity.MovieEntity; +import com.example.app.movie.out.persistence.entity.MovieTheaterEntity; +import com.example.app.movie.out.persistence.entity.ShowtimeEntity; +import com.example.app.movie.type.MovieGenre; +import com.example.app.movie.type.MovieRating; +import com.example.app.movie.type.MovieStatus; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.api.type.TypeReference; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import com.querydsl.core.types.ExpressionUtils; +import net.jqwik.api.Arbitraries; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.Set; +import java.util.stream.Stream; + +import static com.example.app.movie.out.persistence.entity.QMovieEntity.movieEntity; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import({QuerydslConfig.class}) +public class MovieRepositoryTest { + + private FixtureMonkey fixtureMonkey; + + @Autowired + private MovieRepository sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void save_findAllBy_테스트() { + var movies1 = fixtureMonkey.giveMeBuilder(MovieEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(String.class, "title") + .parameter(String.class) + .parameter(MovieStatus.class) + .parameter(MovieRating.class) + .parameter(MovieGenre.class) + .parameter(String.class) + .parameter(int.class) + .parameter(LocalDate.class) + .parameter(new TypeReference>() {}, "showtimes") + .parameter(new TypeReference>() {}, "movieTheaters")) + .set("title", "탑건:"+Arbitraries.strings()) + .set("showtimes", null) + .set("movieTheaters", null) + .sampleList(2); + + var movies2 = fixtureMonkey.giveMeBuilder(MovieEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(String.class, "title") + .parameter(String.class) + .parameter(MovieStatus.class) + .parameter(MovieRating.class) + .parameter(MovieGenre.class) + .parameter(String.class) + .parameter(int.class) + .parameter(LocalDate.class) + .parameter(new TypeReference>() {}, "showtimes") + .parameter(new TypeReference>() {}, "movieTheaters")) + .set("title", "myMovie") + .set("showtimes", null) + .set("movieTheaters", null) + .sampleList(5); + + sut.saveAll(Stream.concat(movies1.stream(), movies2.stream()).toList()); + + var predicate = ExpressionUtils.allOf(movieEntity.title.contains("탑건")); + + var result = sut.findAllBy(predicate); + + assertEquals(2, result.size()); + } +} diff --git a/infrastructure/src/test/resources/application-test.yml b/infrastructure/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/infrastructure/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/movie-api/build.gradle b/movie-api/build.gradle index b548e1161..dcb32232f 100644 --- a/movie-api/build.gradle +++ b/movie-api/build.gradle @@ -8,6 +8,29 @@ bootJar { enabled = true } -tasks.named('test') { +test { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} + +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + + violationRules { + rule { + element = 'PACKAGE' + includes = ['com.example.app.movie.presentation.controller.*'] + limit { + maximum = 0.80 + } + } + } } \ No newline at end of file diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java b/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java index c7be9ee74..59591f450 100644 --- a/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java +++ b/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java @@ -1,7 +1,10 @@ package com.example.app.movie.presentation.controller; +import com.example.app.common.annotation.ClientIp; import com.example.app.movie.presentation.dto.request.MovieSearchRequest; import com.example.app.movie.presentation.dto.response.MovieResponse; +import com.example.app.movie.presentation.service.RateLimitService; +import com.example.app.movie.presentation.service.RedisRateLimitService; import com.example.app.movie.usecase.SearchMovieUseCase; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -11,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.concurrent.ExecutionException; @RestController @RequiredArgsConstructor @@ -18,9 +22,16 @@ public class MovieController { private final SearchMovieUseCase searchMovieUseCase; + private final RateLimitService rateLimitService; + private final RedisRateLimitService redisRateLimitService; @GetMapping("/movies") - public ResponseEntity> searchMovies(@Valid MovieSearchRequest movieSearchRequest) { + public ResponseEntity> searchMovies( + @Valid MovieSearchRequest movieSearchRequest, + @ClientIp String clientIp) { + + redisRateLimitService.checkAccessLimit(clientIp); + var data = searchMovieUseCase.searchMovies(movieSearchRequest.toMovieSearchCommand()) .stream() .map(MovieResponse::toResponse) diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/service/RateLimitService.java b/movie-api/src/main/java/com/example/app/movie/presentation/service/RateLimitService.java new file mode 100644 index 000000000..3df919b73 --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/service/RateLimitService.java @@ -0,0 +1,53 @@ +package com.example.app.movie.presentation.service; + +import com.example.app.common.exception.RateLimitException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.RateLimiter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@SuppressWarnings("UnstableApiUsage") +@Service +@RequiredArgsConstructor +public class RateLimitService { + + private final Cache rateLimiters = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + + private final Cache blockedIps = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + + private final Cache requestCounts = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + + public void checkAccessLimit(String clientIp) throws ExecutionException { + var isBlocked = blockedIps.getIfPresent(clientIp) != null; + + if (isBlocked) { + throw new RateLimitException(); + } + + RateLimiter rateLimiter = rateLimiters.get(clientIp, () -> RateLimiter.create(1.0)); + + AtomicInteger count = requestCounts.get(clientIp, () -> new AtomicInteger(0)); + int currentCount = count.incrementAndGet(); + + if (currentCount >= 50) { + blockedIps.put(clientIp, LocalDateTime.now()); + throw new RateLimitException(); + } + + if (!rateLimiter.tryAcquire()) { + throw new RateLimitException(); + } + } +} diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/service/RedisRateLimitService.java b/movie-api/src/main/java/com/example/app/movie/presentation/service/RedisRateLimitService.java new file mode 100644 index 000000000..86d43e75a --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/service/RedisRateLimitService.java @@ -0,0 +1,48 @@ +package com.example.app.movie.presentation.service; + +import com.example.app.common.exception.RateLimitException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RedisRateLimitService { + + private final RedisTemplate redisTemplate; + private final static String PREFIX_REQUEST = "MOVIE:REQ:"; + private final static String PREFIX_BLOCK = "MOVIE:BLOCK:"; + + public void checkAccessLimit(String clientIp) { + var requestKey = PREFIX_REQUEST + clientIp; + var blockKey = PREFIX_BLOCK + clientIp; + + var isBlocked = redisTemplate.hasKey(blockKey); + + if (Boolean.TRUE.equals(isBlocked)) { + throw new RateLimitException(); + } + + var requestCount = redisTemplate.execute(new DefaultRedisScript() { + { + setScriptText(""" + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('EXPIRE', KEYS[1], 60) + end + return count + """); + setResultType(Long.class); + } + }, Collections.singletonList(requestKey)); + + if (requestCount != null && requestCount >= 50) { + redisTemplate.opsForValue().set(blockKey, "1", 1, TimeUnit.HOURS); + throw new RateLimitException(); + } + } +} diff --git a/movie-api/src/test/java/com/example/MovieApiApplicationTests.java b/movie-api/src/test/java/com/example/MovieApiApplicationTests.java new file mode 100644 index 000000000..34fe07c1e --- /dev/null +++ b/movie-api/src/test/java/com/example/MovieApiApplicationTests.java @@ -0,0 +1,7 @@ +package com.example; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class MovieApiApplicationTests { +} diff --git a/movie-api/src/test/java/com/example/app/movie/presentation/config/EmbeddedRedisConfig.java b/movie-api/src/test/java/com/example/app/movie/presentation/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..27357b940 --- /dev/null +++ b/movie-api/src/test/java/com/example/app/movie/presentation/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.movie.presentation.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} diff --git a/movie-api/src/test/java/com/example/app/movie/presentation/controller/MovieControllerTest.java b/movie-api/src/test/java/com/example/app/movie/presentation/controller/MovieControllerTest.java new file mode 100644 index 000000000..e1e19f900 --- /dev/null +++ b/movie-api/src/test/java/com/example/app/movie/presentation/controller/MovieControllerTest.java @@ -0,0 +1,43 @@ +package com.example.app.movie.presentation.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.example.app.common.exception.RateLimitException; +import com.example.app.movie.presentation.config.EmbeddedRedisConfig; +import com.example.app.movie.presentation.dto.request.MovieSearchRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +@Sql(scripts = "/movie-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +public class MovieControllerTest { + + @Autowired + private MovieController sut; + + @Test + public void 영화_리스트_검색() { + var searchRequest = new MovieSearchRequest("탑건", "ACTION"); + var response = sut.searchMovies(searchRequest, "127.0.0.1"); + var movies = response.getBody(); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(movies.size(), 2); + } + + @Test + public void Rate_Limit_1분_50요청_테스트() { + var searchRequest = new MovieSearchRequest("탑건", "ACTION"); + + assertThrows(RateLimitException.class, () -> { + for (int i=0; i < 50; i++) { + sut.searchMovies(searchRequest, "127.0.0.1"); + } + }); + } +} diff --git a/movie-api/src/test/resources/application-test.yml b/movie-api/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/movie-api/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/movie-api/src/test/resources/movie-data.sql b/movie-api/src/test/resources/movie-data.sql new file mode 100644 index 000000000..fb8112d39 --- /dev/null +++ b/movie-api/src/test/resources/movie-data.sql @@ -0,0 +1,36 @@ +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (1,'나 홀로 집에','크리스마스 휴가 당일, 늦잠을 자 정신없이 공항으로 출발한 맥콜리스터 가족은 전날 부린 말썽에 대한 벌로 다락방에 들어가 있던 8살 케빈을 깜박 잊고 프랑스로 떠나버린다. 매일 형제들에게 치이며 가족이 전부 없어졌으면 좋겠다고 생각한 케빈은 갑자기 찾아온 자유를 만끽한다.','SHOWING','ALL_AGES','COMEDY','https://m.media-amazon.com/images/M/MV5BNzNmNmQ2ZDEtMTc1MS00NjNiLThlMGUtZmQxNTg1Nzg5NWMzXkEyXkFqcGc@._V1_QL75_UX190_CR0,1,190,281_.jpg',103,'1991-07-06','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (2,'탑건','최고의 파일럿들만이 갈 수 있는 캘리포니아의 한 비행 조종 학교 탑건에서의 사나이들의 우정과 사랑의 모험이 시작된다. 자신을 좇는 과거의 기억과 경쟁자, 그리고 사랑 사이에서 고군분투하는 그의 여정이 펼쳐진다.','SHOWING','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BZmVjNzQ3MjYtYTZiNC00Y2YzLWExZTEtMTM2ZDllNDI0MzgyXkEyXkFqcGc@._V1_QL75_UX190_CR0,6,190,281_.jpg',109,'1986-05-12','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (3,'탑건:메버릭','해군 최고의 비행사 피트 미첼은 비행 훈련소에서 갓 졸업을 한 신입 비행사들 팀의 훈련을 맡게 된다. 자신을 좇는 과거의 기억과 위험천만한 임무 속에서 고군분투하는 그의 비상이 펼쳐진다.','SHOWING','TWELVE_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BMDBkZDNjMWEtOTdmMi00NmExLTg5MmMtNTFlYTJlNWY5YTdmXkEyXkFqcGc@._V1_QL75_UX190_CR0,0,190,281_.jpg',130,'2022-05-18','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (4,'하얼빈','1908년 함경북도 신아산에서 안중근이 이끄는 독립군들은 일본군과의 전투에서 큰 승리를 거둔다. 대한의군 참모중장 안중근은 만국공법에 따라 전쟁포로인 일본인들을 풀어주게 되고, 이 사건으로 인해 독립군 사이에서는 안중근에 대한 의심과 함께 균열이 일기 시작한다.','SCHEDULED','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BNmY4YzM5NzUtMTg4Yy00Yzc3LThlZDktODk4YjljZmNlODA0XkEyXkFqcGc@._V1_QL75_UY281_CR4,0,190,281_.jpg',113,'2024-12-24','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (1,1,'08:00:00','09:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (2,1,'10:00:00','11:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (3,1,'13:00:00','14:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (4,1,'15:30:00','17:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (5,2,'10:30:00','12:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (6,2,'14:30:00','16:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (7,3,'11:30:00','14:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (8,3,'15:40:00','17:25:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (9,3,'18:50:00','20:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (10,3,'07:30:00','09:50:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (11,4,'11:10:00','13:05:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (1,'강남점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (2,'강북점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (3,'봉천점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (4,'안양점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (5,'평촌점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (6,'인덕원점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (7,'사당점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (8,'삼성점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (9,'신림점','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (1,1,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (2,2,3,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (3,3,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (4,1,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (5,1,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (6,2,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (7,2,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (8,3,2,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (9,4,8,'2025-01-09 00:00:00','2025-01-09 00:00:00'); \ No newline at end of file