From bcc18d2103661e7d9e922aec509dc9161c3592da Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 18:15:45 +0900 Subject: [PATCH 01/46] add show_list_inquiry.md for show listing API documentation --- docs/specs/api/show_list_inquiry.md | 124 ++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/specs/api/show_list_inquiry.md diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md new file mode 100644 index 0000000..6188664 --- /dev/null +++ b/docs/specs/api/show_list_inquiry.md @@ -0,0 +1,124 @@ +# 공연 목록 조회 + +--- + +## 개요 + +- 공연(Show)의 목록을 조회한다. +- 검색 조건(type, rating, q, from~to)을 통해 필터링할 수 있으며, 결과는 페이지네이션된다. +- 반환 데이터는 공연 요약 정보로, 상세(회차, 가격 등)는 포함하지 않는다. + +--- + +## 요청 + +- 메서드: `GET` +- 경로: `/api/show` +- 헤더 + + ``` + Content-Type: application/json + ``` + +- 쿼리 파라미터 + - page (선택, 기본=0, 정수 >= 0): 페이지 번호 + - size (선택, 기본=10, 1~100): 페이지 크기 + - type (선택): 공연 유형 (MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) + - rating (선택): 관람 등급 (ALL, AGE12, AGE15, AGE18) + - q (선택): 공연 제목 검색 키워드 + - from (선택, yyyy-MM-dd): 조회 시작일 + - to (선택, yyyy-MM-dd): 조회 종료일 + -> 공연 기간(performanceStartDate~performanceEndDate)이 [from, to] 구간과 겹치는 공연만 반환한다. + - 기간 필터링: 공연 기간 [performanceStartDate, performanceEndDate] 와 조회 기간 [from, to] 가 **겹치면** 포함한다. + - 경계 포함(폐구간): performanceStartDate <= to AND performanceEndDate >= from + - from만 지정 시: performanceEndDate >= from + - to만 지정 시: performanceStartDate <= to + - from > to 인 경우: 400 BAD_REQUEST + + - 정렬: + 1) performanceStartDate ASC + 2) title ASC + 3) showId ASC (타이 브레이커 최종) + + - 페이지네이션: + - page는 0-기반 인덱스다. + - hasNext = (page < totalPages - 1) + +- curl 명령 예시 + + ```bash + curl -i -X GET 'http://localhost:8080/api/show?page=0&size=5&type=MUSICAL&from=2025-10-01&to=2025-10-31&q=라라' \ + -H 'Content-Type: application/json' + ``` + +--- + +## 응답 + +- 상태코드: `200 OK` + - 본문 예시 + + ```json + { + "status": "SUCCESS", + "data": { + "content": [ + { + "showId": 1, + "title": "라라랜드", + "type": "MUSICAL", + "rating": "ALL", + "posterUrl": "https://example.com/posters/lalaland.jpg", + "venueName": "샤롯데씨어터", + "performanceStartDate": "2025-10-05", + "performanceEndDate": "2025-11-05" + }, + { + "showId": 2, + "title": "라라랜드 2", + "type": "MUSICAL", + "rating": "AGE12", + "posterUrl": "https://example.com/posters/lalaland2.jpg", + "venueName": "샤롯데씨어터", + "performanceStartDate": "2025-10-10", + "performanceEndDate": "2025-11-10" + } + ], + "page": 0, + "size": 5, + "totalElements": 2, + "totalPages": 1, + "hasNext": false + }, + "timestamp": "2025-09-17T12:34:56.789Z" + } + + ``` + +--- + +## 테스트 + +- [ ] Authorization 헤더가 없더라도 접근 가능하다 +- [ ] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 +- [ ] 기본 요청 시 첫번째 페이지의 10건이 반환된다 +- [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 +- [ ] 각 항목은 showId, title, type, rating, posterUrl, performanceStartDate, performanceEndDate만 포함한다 +- [ ] 공연이 존재하지 않을 경우 빈 content, totalElements=0, hasNext=false를 반환한다 +- [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 +- [ ] page=1&size=1 -> 두 번째 건이 반환된다 +- [ ] 초과 페이지 요청 시 빈 content, hasNext=false를 반환한다 +- [ ] size > 100 요청 시 400 BAD_REQUEST를 반환한다 +- [ ] page=-1 요청 시 400 BAD_REQUEST를 반환한다 +- [ ] size=0 요청 시 400 BAD_REQUEST를 반환한다 +- [ ] type=MUSICAL -> MUSICAL만 조회된다 +- [ ] rating=AGE12 -> AGE12 공연만 조회된다 +- [ ] 부적절한 rating으로 요청하는 경우 400 BAD_REQUEST를 반환한다 +- [ ] q=라라 -> 제목에 "라라"가 포함된 공연만 조회된다 +- [ ] from=2025-10-01&to=2025-10-31 -> 이 기간과 겹치는 공연만 조회된다 +- [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 +- [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 +- [ ] 기간이 서로 맞물리지 않는 경우 빈 content를 반환한다 +- [ ] type/rating에 허용되지 않는 값 입력 시 400 BAD_REQUEST를 반환한다 +- [ ] from 또는 to 형식이 잘못된 경우 400 BAD_REQUEST를 반환한다 +- [ ] from > to인 경우 400 BAD_REQUEST를 반환한다 From 5ae9a2b81f7f2e2da0062faf95111b457b1e1e6b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 19:08:38 +0900 Subject: [PATCH 02/46] add integration test for GET /api/show endpoint and update documentation --- .../booking/webapi/show/GET_specs.java | 26 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java new file mode 100644 index 0000000..625cf6e --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -0,0 +1,26 @@ +package org.mandarin.booking.webapi.show; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mandarin.booking.utils.IntegrationTest; +import org.mandarin.booking.utils.IntegrationTestUtils; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("GET /api/show") +@IntegrationTest +public class GET_specs { + + @Test + void Authorization_헤더가_없더라도_접근하더라도_401_Unauthorized가_발생하지_않는다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + testUtils.get("/api/show") + .assertSuccess(Void.class); + + // Assert + + } +} diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 6188664..e5b90a1 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -99,7 +99,7 @@ ## 테스트 -- [ ] Authorization 헤더가 없더라도 접근 가능하다 +- [x] Authorization 헤더가 없더라도 접근하더라도 401 Unauthorized가 발생하지 않는다 - [ ] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 - [ ] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 From e687292e75b9815318813776c2e7deba33dcefac Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 19:09:21 +0900 Subject: [PATCH 03/46] update DocsUtils and IntegrationTestUtils to support variable path parameters in HTTP requests --- .../java/org/mandarin/booking/utils/DocsUtils.java | 13 +++++++------ .../booking/utils/IntegrationTestUtils.java | 8 ++++---- .../java/org/mandarin/booking/utils/TestResult.java | 9 ++++++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java b/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java index 03f274c..3952906 100644 --- a/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/DocsUtils.java @@ -32,7 +32,8 @@ public record DocsUtils(Environment environment, private static volatile boolean started = false; - public String execute(String method, String path, Object requestBody, Map headers) + public String execute(String method, String path, Object requestBody, Map headers, + Object... pathParams) throws Exception { var baseSnippet = sanitize(method, path, false); var methodSpecificSnippet = sanitize(method, path, true); @@ -55,11 +56,11 @@ public String execute(String method, String path, Object requestBody, Map spec.when().get(path); - case "POST" -> spec.when().post(path); - case "PUT" -> spec.when().put(path); - case "PATCH" -> spec.when().patch(path); - case "DELETE" -> spec.when().delete(path); + case "GET" -> spec.when().get(path, pathParams); + case "POST" -> spec.when().post(path, pathParams); + case "PUT" -> spec.when().put(path, pathParams); + case "PATCH" -> spec.when().patch(path, pathParams); + case "DELETE" -> spec.when().delete(path, pathParams); default -> throw new IllegalArgumentException("Unsupported method: " + method); }; return resp.then().extract().asString(); diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index 859b7f6..ba5311e 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -37,16 +37,16 @@ public record IntegrationTestUtils(MemberCommandRepository memberRepository, objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } - public TestResult get(String path) { - return new TestResult(path, null) + public TestResult get(String path, Object... requestParams) { + return new TestResult(path, null, requestParams) .setContext(objectMapper) - .setExecutor((p, req, headers) -> docsUtils.execute("GET", p, null, headers)); + .setExecutor((p, req, headers, params) -> docsUtils.execute("GET", p, null, headers, params)); } public TestResult post(String path, T request) { return new TestResult(path, request) .setContext(objectMapper) - .setExecutor((p, req, headers) -> docsUtils.execute("POST", p, req, headers)); + .setExecutor((p, req, headers, params) -> docsUtils.execute("POST", p, req, headers, params)); } public String getValidRefreshToken() { diff --git a/application/src/test/java/org/mandarin/booking/utils/TestResult.java b/application/src/test/java/org/mandarin/booking/utils/TestResult.java index d4068f8..d10e3b5 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestResult.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestResult.java @@ -16,13 +16,15 @@ public class TestResult { private final String path; private final Object request; + private final Object[] requestParams; private final Map headers = new HashMap<>(); private Executor executor; private ObjectMapper objectMapper; - public TestResult(String path, Object request) { + public TestResult(String path, Object request, Object... requestParams) { this.path = path; this.request = request; + this.requestParams = requestParams; } public ApiResponse assertSuccess(Class responseType) { @@ -162,7 +164,7 @@ private ErrorResponse readErrorResponse() { private String getResponse() { if (executor != null) { try { - return executor.execute(path, request, headers); + return executor.execute(path, request, headers, requestParams); } catch (Exception e) { throw new AssertionError("Request execution failed: " + e.getMessage(), e); } @@ -173,6 +175,7 @@ private String getResponse() { @FunctionalInterface public interface Executor { - String execute(String path, Object request, Map headers) throws Exception; + String execute(String path, Object request, Map headers, Object... requestParams) + throws Exception; } } From 5b38e4d0e69a5e813e388ee0d6b9acf498ee98e7 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 19:09:30 +0900 Subject: [PATCH 04/46] add inquire endpoint to ShowController for show inquiries --- .../mandarin/booking/adapter/webapi/ShowController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 2a98764..67f293f 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -6,6 +6,8 @@ import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -15,6 +17,11 @@ @RequestMapping("/api/show") record ShowController(ShowRegisterer showRegisterer) { + @GetMapping + Page inquire() { + return null; + } + @PostMapping ShowRegisterResponse register(@RequestBody @Valid ShowRegisterRequest request) { return showRegisterer.register(request); From 9738e26ab755d83c8f61ac25117470dc401e4ec0 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 19:31:56 +0900 Subject: [PATCH 05/46] implement authorization request matchers for API endpoints --- ...AuthorizationRequestMatcherConfigurer.java | 24 +++++++++++++++++++ .../adapter/security/package-info.java | 4 ++++ ...AuthorizationRequestMatcherConfigurer.java | 11 +++++++++ ...AuthorizationRequestMatcherConfigurer.java | 13 ++++++++++ .../booking/adapter/SecurityConfig.java | 20 ++++++++-------- 5 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java create mode 100644 application/src/main/java/org/mandarin/booking/adapter/security/package-info.java create mode 100644 internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java create mode 100644 internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java new file mode 100644 index 0000000..9e0c056 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/adapter/security/ApplicationAuthorizationRequestMatcherConfigurer.java @@ -0,0 +1,24 @@ +package org.mandarin.booking.adapter.security; + +import org.mandarin.booking.adapter.AuthorizationRequestMatcherConfigurer; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.stereotype.Component; + +@Component +class ApplicationAuthorizationRequestMatcherConfigurer implements AuthorizationRequestMatcherConfigurer { + @Override + public void authorizeRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth + ) { + auth + .requestMatchers(HttpMethod.POST, "/api/member").permitAll() + .requestMatchers("/api/auth/login").permitAll() + .requestMatchers("/api/auth/reissue").permitAll() + .requestMatchers(HttpMethod.POST, "/api/show/schedule").hasAuthority("ROLE_DISTRIBUTOR") + .requestMatchers(HttpMethod.GET, "/api/show").permitAll() + .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") + .anyRequest().authenticated(); + } +} diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/package-info.java b/application/src/main/java/org/mandarin/booking/adapter/security/package-info.java new file mode 100644 index 0000000..b212696 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/adapter/security/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.mandarin.booking.adapter.security; + +import org.jspecify.annotations.NullMarked; diff --git a/internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java b/internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java new file mode 100644 index 0000000..55ed82e --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/AuthorizationRequestMatcherConfigurer.java @@ -0,0 +1,11 @@ +package org.mandarin.booking.adapter; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +public interface AuthorizationRequestMatcherConfigurer { + + void authorizeRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth + ); +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java b/internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java new file mode 100644 index 0000000..73b5194 --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java @@ -0,0 +1,13 @@ +package org.mandarin.booking.adapter; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +class DefaultAuthorizationRequestMatcherConfigurer implements AuthorizationRequestMatcherConfigurer { + @Override + public void authorizeRequests( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth + ) { + auth.anyRequest().authenticated(); + } +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java index a277d6f..203fa94 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java @@ -1,10 +1,10 @@ package org.mandarin.booking.adapter; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; -import org.springframework.http.HttpMethod; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.hierarchicalroles.RoleHierarchy; @@ -29,23 +29,23 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + @ConditionalOnMissingBean + public AuthorizationRequestMatcherConfigurer authorizationRequestMatcherConfigurer() { + return new DefaultAuthorizationRequestMatcherConfigurer(); + } + @Bean @Order(1) public SecurityFilterChain apiChain(HttpSecurity http, AuthenticationProvider authenticationProvider, AuthenticationEntryPoint authenticationEntryPoint, - AccessDeniedHandler accessDeniedHandler) + AccessDeniedHandler accessDeniedHandler, + AuthorizationRequestMatcherConfigurer authorizationRequestMatcherConfigurer) throws Exception { http .securityMatcher("/api/**") - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST, "/api/member").permitAll() - .requestMatchers("/api/auth/login").permitAll() - .requestMatchers("/api/auth/reissue").permitAll() - .requestMatchers(HttpMethod.POST, "/api/show/schedule").hasAuthority("ROLE_DISTRIBUTOR") - .requestMatchers(HttpMethod.POST, "/api/show").hasAuthority("ROLE_ADMIN") - .anyRequest().authenticated() - ) + .authorizeHttpRequests(authorizationRequestMatcherConfigurer::authorizeRequests) .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) From b0606edbf84eff04cc1c71829461d9d10eb53517 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 19:41:03 +0900 Subject: [PATCH 06/46] add assertion to verify unauthorized status in GET /api/show integration test --- .../java/org/mandarin/booking/webapi/show/GET_specs.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 625cf6e..ace62a9 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -1,5 +1,8 @@ package org.mandarin.booking.webapi.show; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.utils.IntegrationTest; @@ -17,10 +20,10 @@ public class GET_specs { // Arrange // Act - testUtils.get("/api/show") + var response = testUtils.get("/api/show") .assertSuccess(Void.class); // Assert - + assertThat(response.getStatus()).isNotEqualTo(UNAUTHORIZED); } } From b6d89e45f0ac09cf783f89418ad5db7b8e86b0c8 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 19:54:12 +0900 Subject: [PATCH 07/46] add test for handling invalid or expired tokens in GET /api/show --- .../mandarin/booking/webapi/show/GET_specs.java | 17 +++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index ace62a9..7096b64 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -1,6 +1,7 @@ package org.mandarin.booking.webapi.show; import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; import org.junit.jupiter.api.DisplayName; @@ -26,4 +27,20 @@ public class GET_specs { // Assert assertThat(response.getStatus()).isNotEqualTo(UNAUTHORIZED); } + + @Test + void 잘못된_토큰이나_만료_토큰을_전달해도_정상_응답을_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var wrongToken = "wrong_token"; + + // Act + var response = testUtils.get("/api/show") + .withAuthorization(wrongToken) + .assertSuccess(Void.class); + + // Assert + assertThat(response.getStatus()).isEqualTo(SUCCESS); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index e5b90a1..b3b725a 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -100,7 +100,7 @@ ## 테스트 - [x] Authorization 헤더가 없더라도 접근하더라도 401 Unauthorized가 발생하지 않는다 -- [ ] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 +- [x] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 - [ ] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 - [ ] 각 항목은 showId, title, type, rating, posterUrl, performanceStartDate, performanceEndDate만 포함한다 From ace025bffe05a8e8bd24d9310fd5cd3e779009bb Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 20:04:34 +0900 Subject: [PATCH 08/46] update content references to contents in show list inquiry documentation --- docs/specs/api/show_list_inquiry.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index b3b725a..65cf44a 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -62,7 +62,7 @@ { "status": "SUCCESS", "data": { - "content": [ + "contents": [ { "showId": 1, "title": "라라랜드", @@ -104,10 +104,10 @@ - [ ] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 - [ ] 각 항목은 showId, title, type, rating, posterUrl, performanceStartDate, performanceEndDate만 포함한다 -- [ ] 공연이 존재하지 않을 경우 빈 content, totalElements=0, hasNext=false를 반환한다 +- [ ] 공연이 존재하지 않을 경우 빈 contents, totalElements=0, hasNext=false를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] 초과 페이지 요청 시 빈 content, hasNext=false를 반환한다 +- [ ] 초과 페이지 요청 시 빈 contents, hasNext=false를 반환한다 - [ ] size > 100 요청 시 400 BAD_REQUEST를 반환한다 - [ ] page=-1 요청 시 400 BAD_REQUEST를 반환한다 - [ ] size=0 요청 시 400 BAD_REQUEST를 반환한다 @@ -118,7 +118,7 @@ - [ ] from=2025-10-01&to=2025-10-31 -> 이 기간과 겹치는 공연만 조회된다 - [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 -- [ ] 기간이 서로 맞물리지 않는 경우 빈 content를 반환한다 +- [ ] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 - [ ] type/rating에 허용되지 않는 값 입력 시 400 BAD_REQUEST를 반환한다 - [ ] from 또는 to 형식이 잘못된 경우 400 BAD_REQUEST를 반환한다 - [ ] from > to인 경우 400 BAD_REQUEST를 반환한다 From bba31a0774454b0287ee96a3b8c9d4477dc1c889 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Wed, 17 Sep 2025 21:06:44 +0900 Subject: [PATCH 09/46] add inquire endpoint and response structure for show inquiries --- .../adapter/webapi/ShowController.java | 31 +++++++++- .../booking/webapi/show/GET_specs.java | 18 ++++++ docs/specs/api/show_list_inquiry.md | 62 +++++++++---------- .../booking/domain/show/ShowResponse.java | 17 +++++ .../mandarin/booking/adapter/SliceView.java | 16 +++++ 5 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java create mode 100644 internal/src/main/java/org/mandarin/booking/adapter/SliceView.java diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 67f293f..c01624f 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -1,16 +1,23 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowRegisterer; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; -import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -18,8 +25,26 @@ record ShowController(ShowRegisterer showRegisterer) { @GetMapping - Page inquire() { - return null; + SliceView inquire(@RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) String type, + @RequestParam(required = false) String rating, + @RequestParam(required = false) String q, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return new SliceView<>(List.of( + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), + new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()) + ), + 0); } @PostMapping diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 7096b64..60f802e 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -4,8 +4,11 @@ import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; +import com.fasterxml.jackson.core.type.TypeReference; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -43,4 +46,19 @@ public class GET_specs { // Assert assertThat(response.getStatus()).isEqualTo(SUCCESS); } + + @Test + void 기본_요청_시_첫번째_페이지의_10건이_반환된다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().size()).isEqualTo(10); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 65cf44a..1e12254 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -42,7 +42,7 @@ - 페이지네이션: - page는 0-기반 인덱스다. - - hasNext = (page < totalPages - 1) + - hasNext = 존재성 여부만 확인 - curl 명령 예시 @@ -62,37 +62,35 @@ { "status": "SUCCESS", "data": { - "contents": [ - { - "showId": 1, - "title": "라라랜드", - "type": "MUSICAL", - "rating": "ALL", - "posterUrl": "https://example.com/posters/lalaland.jpg", - "venueName": "샤롯데씨어터", - "performanceStartDate": "2025-10-05", - "performanceEndDate": "2025-11-05" - }, - { - "showId": 2, - "title": "라라랜드 2", - "type": "MUSICAL", - "rating": "AGE12", - "posterUrl": "https://example.com/posters/lalaland2.jpg", - "venueName": "샤롯데씨어터", - "performanceStartDate": "2025-10-10", - "performanceEndDate": "2025-11-10" - } - ], - "page": 0, - "size": 5, - "totalElements": 2, - "totalPages": 1, - "hasNext": false + "contents": [ + { + "showId": 1, + "title": "라라랜드", + "type": "MUSICAL", + "rating": "ALL", + "posterUrl": "https://example.com/posters/lalaland.jpg", + "venueName": "샤롯데씨어터", + "performanceStartDate": "2025-10-05", + "performanceEndDate": "2025-11-05" + }, + { + "showId": 2, + "title": "라라랜드 2", + "type": "MUSICAL", + "rating": "AGE12", + "posterUrl": "https://example.com/posters/lalaland2.jpg", + "venueName": "샤롯데씨어터", + "performanceStartDate": "2025-10-10", + "performanceEndDate": "2025-11-10" + } + ], + "page": 0, + "size": 5, + "hasNext": false }, "timestamp": "2025-09-17T12:34:56.789Z" - } - + } + ``` --- @@ -101,10 +99,10 @@ - [x] Authorization 헤더가 없더라도 접근하더라도 401 Unauthorized가 발생하지 않는다 - [x] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 -- [ ] 기본 요청 시 첫번째 페이지의 10건이 반환된다 +- [x] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 - [ ] 각 항목은 showId, title, type, rating, posterUrl, performanceStartDate, performanceEndDate만 포함한다 -- [ ] 공연이 존재하지 않을 경우 빈 contents, totalElements=0, hasNext=false를 반환한다 +- [ ] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] 초과 페이지 요청 시 빈 contents, hasNext=false를 반환한다 diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java new file mode 100644 index 0000000..9f0c2c0 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java @@ -0,0 +1,17 @@ +package org.mandarin.booking.domain.show; + +import java.time.LocalDate; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; + +public record ShowResponse( + Long showId, + String title, + Type type, + Rating rating, + String posterUrl, + String venueName, + LocalDate performanceStartDate, + LocalDate performanceEndDate +) { +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java new file mode 100644 index 0000000..af6fe5b --- /dev/null +++ b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java @@ -0,0 +1,16 @@ +package org.mandarin.booking.adapter; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record SliceView( + @JsonProperty("contents") List contents, + @JsonProperty("page") int page, + @JsonProperty("size") int size, + @JsonProperty("hasNext") boolean hasNext +) { + + public SliceView(List contents, int page) { + this(contents, page, contents.size(), false); + } +} From b255465b692597c1603d6824b8d0d2fe57ffc87f Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 10:11:48 +0900 Subject: [PATCH 10/46] add test to verify empty contents and hasNext=false when no shows exist --- .../mandarin/booking/webapi/show/GET_specs.java | 15 +++++++++++++++ docs/specs/api/show_list_inquiry.md | 7 +++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 60f802e..273dd4d 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -61,4 +61,19 @@ public class GET_specs { // Assert assertThat(response.getData().contents().size()).isEqualTo(10); } + + @Test + void 공연이_존재하지_않을_경우_빈_contents_hasNext는false를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 1e12254..4c57879 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -93,16 +93,14 @@ ``` ---- +---고 ## 테스트 - [x] Authorization 헤더가 없더라도 접근하더라도 401 Unauthorized가 발생하지 않는다 - [x] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 - [x] 기본 요청 시 첫번째 페이지의 10건이 반환된다 -- [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 -- [ ] 각 항목은 showId, title, type, rating, posterUrl, performanceStartDate, performanceEndDate만 포함한다 -- [ ] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 +- [x] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] 초과 페이지 요청 시 빈 contents, hasNext=false를 반환한다 @@ -113,6 +111,7 @@ - [ ] rating=AGE12 -> AGE12 공연만 조회된다 - [ ] 부적절한 rating으로 요청하는 경우 400 BAD_REQUEST를 반환한다 - [ ] q=라라 -> 제목에 "라라"가 포함된 공연만 조회된다 +- [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 - [ ] from=2025-10-01&to=2025-10-31 -> 이 기간과 겹치는 공연만 조회된다 - [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 From aeb2d91735aa1c9916673d827ab288952ff4670a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 20:24:00 +0900 Subject: [PATCH 11/46] implement show fetching functionality and add utility classes for enum handling --- .../adapter/webapi/ShowController.java | 20 +----- .../booking/app/show/ShowFetcher.java | 10 +++ .../booking/app/show/ShowQueryRepository.java | 65 +++++++++++++++++++ .../booking/app/show/ShowService.java | 17 ++++- .../mandarin/booking/utils/EnumFixture.java | 7 ++ .../booking/utils/IntegrationTestUtils.java | 31 +++++++++ .../booking/webapi/show/GET_specs.java | 25 +++++++ docs/specs/api/show_list_inquiry.md | 1 + .../mandarin/booking/domain/EnumUtils.java | 12 ++++ .../booking/domain/show/ShowResponse.java | 5 ++ .../mandarin/booking/adapter/SliceView.java | 6 +- 11 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java create mode 100644 application/src/test/java/org/mandarin/booking/utils/EnumFixture.java create mode 100644 domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index c01624f..589432c 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -2,11 +2,9 @@ import jakarta.validation.Valid; import java.time.LocalDate; -import java.util.List; import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.app.show.ShowFetcher; import org.mandarin.booking.app.show.ShowRegisterer; -import org.mandarin.booking.domain.show.Show.Rating; -import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.show.ShowResponse; @@ -22,7 +20,7 @@ @RestController @RequestMapping("/api/show") -record ShowController(ShowRegisterer showRegisterer) { +record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping SliceView inquire(@RequestParam(required = false) Integer page, @@ -32,19 +30,7 @@ SliceView inquire(@RequestParam(required = false) Integer page, @RequestParam(required = false) String q, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return new SliceView<>(List.of( - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()), - new ShowResponse(0L, "", Type.MUSICAL, Rating.ALL, "", "", LocalDate.now(), LocalDate.now()) - ), - 0); + return showFetcher.fetchShows(page, size, type, rating, q, from, to); } @PostMapping diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java b/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java new file mode 100644 index 0000000..89da51c --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowFetcher.java @@ -0,0 +1,10 @@ +package org.mandarin.booking.app.show; + +import java.time.LocalDate; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.ShowResponse; + +public interface ShowFetcher { + SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, + LocalDate from, LocalDate to); +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index b397472..a369f7d 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -1,13 +1,24 @@ package org.mandarin.booking.app.show; +import static org.mandarin.booking.domain.show.QShow.show; import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.QShowResponse; import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowException; +import org.mandarin.booking.domain.show.ShowResponse; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -37,4 +48,58 @@ public boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime e .fetchFirst(); return fetchFirst == null; } + + public SliceView fetch(@Nullable Integer page, + @Nullable Integer size, + @Nullable Type type, + @Nullable Rating rating, + @Nullable String q, + @Nullable LocalDate from, + @Nullable LocalDate to) { + + int pageNo = (page != null && page >= 0) ? page : 0; + int pageSize = (size != null && size > 0) ? size : 10; + + BooleanBuilder builder = new BooleanBuilder(); + if (type != null) { + builder.and(show.type.eq(type)); + } + if (rating != null) { + builder.and(show.rating.eq(rating)); + } + if (q != null && !q.isBlank()) { + builder.and(show.title.containsIgnoreCase(q)); + } + if (from != null && to != null) { + builder.and(show.performanceStartDate.between(from, to)); + } else if (from != null) { + builder.and(show.performanceStartDate.goe(from)); + } else if (to != null) { + builder.and(show.performanceStartDate.loe(to)); + } + + List results = queryFactory + .select(new QShowResponse( + show.id, + show.title, + show.type, + show.rating, + show.posterUrl, + Expressions.nullExpression(), // venueName + show.performanceStartDate, + show.performanceEndDate)) + .from(show) + .where(builder) + .orderBy(show.performanceStartDate.asc()) + .offset((long) pageNo * pageSize) + .limit(pageSize + 1) + .fetch(); + + boolean hasNext = results.size() > pageSize; + if (hasNext) { + results.remove(pageSize); + } + + return new SliceView<>(results, pageNo, pageSize, hasNext); + } } diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index e75e755..d96bf24 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -1,14 +1,20 @@ package org.mandarin.booking.app.show; import static java.util.Objects.requireNonNull; +import static org.mandarin.booking.domain.EnumUtils.nullableEnum; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; +import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.venue.HallValidator; import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowException; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.domain.show.ShowScheduleCreateCommand; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; @@ -16,7 +22,7 @@ @Service @RequiredArgsConstructor -public class ShowService implements ShowRegisterer { +public class ShowService implements ShowRegisterer, ShowFetcher { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; private final HallValidator hallValidator; @@ -47,6 +53,14 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); } + @Override + public SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, + LocalDate from, LocalDate to) { + return queryRepository.fetch(page, size, + nullableEnum(Type.class, type), nullableEnum(Rating.class, rating), + q, from, to); + } + private void checkDuplicateTitle(String title) { if (queryRepository.existsByName(title)) { throw new ShowException("이미 존재하는 공연 이름입니다:" + title); @@ -58,5 +72,6 @@ private void checkConflictSchedule(Long hallId, ShowScheduleRegisterRequest requ throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } } + } diff --git a/application/src/test/java/org/mandarin/booking/utils/EnumFixture.java b/application/src/test/java/org/mandarin/booking/utils/EnumFixture.java new file mode 100644 index 0000000..434747e --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/EnumFixture.java @@ -0,0 +1,7 @@ +package org.mandarin.booking.utils; + +public class EnumFixture { + public static > T randomEnum(Class enumClass) { + return enumClass.getEnumConstants()[(int) (Math.random() * enumClass.getEnumConstants().length)]; + } +} diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index ba5311e..c448219 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -1,5 +1,7 @@ package org.mandarin.booking.utils; +import static org.mandarin.booking.MemberAuthority.ADMIN; +import static org.mandarin.booking.utils.EnumFixture.randomEnum; import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; @@ -21,8 +23,11 @@ import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.venue.Hall; import org.springframework.test.util.ReflectionTestUtils; @@ -127,5 +132,31 @@ public Hall insertDummyHall() { var hall = Hall.create(); return hallRepository.insert(hall); } + + public ShowRegisterResponse generateShow() { + var authToken = getAuthToken(ADMIN); + var request = validShowRegisterRequest(); + + // Act + var response = post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertSuccess(ShowRegisterResponse.class); + return response.getData(); + } + + private ShowRegisterRequest validShowRegisterRequest() { + return new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 273dd4d..e129546 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -5,9 +5,12 @@ import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; import com.fasterxml.jackson.core.type.TypeReference; +import java.util.List; +import java.util.stream.IntStream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; @@ -76,4 +79,26 @@ public class GET_specs { // Assert assertThat(response.getData().hasNext()).isFalse(); } + + @Test + void 실제로_저장된_공연_정보가_조회된다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + List showRegisterResponses = IntStream.range(0, 10) + .mapToObj(i -> testUtils.generateShow()) + .toList(); + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + for (ShowRegisterResponse res : showRegisterResponses) { + assertThat(response.getData().contents().stream() + .anyMatch(show -> show.showId().equals(res.showId()))) + .isTrue(); + } + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 4c57879..80544c0 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -101,6 +101,7 @@ - [x] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 - [x] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [x] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 +- [x] 실제로 저장된 공연 정보가 조회된다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] 초과 페이지 요청 시 빈 contents, hasNext=false를 반환한다 diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java b/domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java new file mode 100644 index 0000000..7ce7921 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumUtils.java @@ -0,0 +1,12 @@ +package org.mandarin.booking.domain; + +import org.jspecify.annotations.Nullable; + +public final class EnumUtils { + public static > T nullableEnum(Class enumClass, @Nullable String value) { + if (value == null) { + return null; + } + return Enum.valueOf(enumClass, value); + } +} diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java index 9f0c2c0..8088a21 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java @@ -1,5 +1,6 @@ package org.mandarin.booking.domain.show; +import com.querydsl.core.annotations.QueryProjection; import java.time.LocalDate; import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.Type; @@ -14,4 +15,8 @@ public record ShowResponse( LocalDate performanceStartDate, LocalDate performanceEndDate ) { + + @QueryProjection + public ShowResponse { + } } diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java index af6fe5b..481824c 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java @@ -2,15 +2,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import org.jspecify.annotations.NullUnmarked; +@NullUnmarked public record SliceView( @JsonProperty("contents") List contents, @JsonProperty("page") int page, @JsonProperty("size") int size, @JsonProperty("hasNext") boolean hasNext ) { - - public SliceView(List contents, int page) { - this(contents, page, contents.size(), false); - } } From 704130150dfcdb91e54d1409db0d654d807ea1e5 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 20:48:46 +0900 Subject: [PATCH 12/46] add tests for pagination and size limits in show inquiries --- .../adapter/webapi/ShowController.java | 3 +- .../booking/utils/IntegrationTestUtils.java | 7 ++++ .../booking/webapi/show/GET_specs.java | 36 ++++++++++++++++--- docs/specs/api/show_list_inquiry.md | 4 +-- .../adapter/GlobalExceptionHandler.java | 7 ++++ 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 589432c..b709d47 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -1,6 +1,7 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import java.time.LocalDate; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; @@ -24,7 +25,7 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping SliceView inquire(@RequestParam(required = false) Integer page, - @RequestParam(required = false) Integer size, + @RequestParam(required = false) @Max(value = 100) Integer size, @RequestParam(required = false) String type, @RequestParam(required = false) String rating, @RequestParam(required = false) String q, diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index c448219..1cd3185 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -13,6 +13,7 @@ import java.util.Collection; import java.util.List; import java.util.UUID; +import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.TokenHolder; import org.mandarin.booking.adapter.TokenUtils; @@ -158,5 +159,11 @@ private ShowRegisterRequest validShowRegisterRequest() { LocalDate.now().plusDays(30) ); } + + public List generateShows() { + return IntStream.range(0, 10) + .mapToObj(i -> generateShow()) + .toList(); + } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index e129546..951107a 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -1,12 +1,12 @@ package org.mandarin.booking.webapi.show; import static org.assertj.core.api.Assertions.assertThat; +import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; -import java.util.stream.IntStream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.adapter.SliceView; @@ -85,9 +85,7 @@ public class GET_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - List showRegisterResponses = IntStream.range(0, 10) - .mapToObj(i -> testUtils.generateShow()) - .toList(); + List showRegisterResponses = testUtils.generateShows(); // Act var response = testUtils.get("/api/show") @@ -101,4 +99,34 @@ public class GET_specs { .isTrue(); } } + + @Test + void 초과_페이지_요청_시_빈_contents와_hasNext는_false를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?page=2") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents()).isEmpty(); + assertThat(response.getData().hasNext()).isFalse(); + } + + @Test + void size가_100보다_큰_요청_시_400_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?size=101") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 80544c0..b61cbc4 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -102,10 +102,10 @@ - [x] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [x] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 - [x] 실제로 저장된 공연 정보가 조회된다 +- [x] 초과 페이지 요청 시 빈 contents와 hasNext=false를 반환한다 +- [x] size가 100보다 큰 요청 시 400 BAD_REQUEST를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] 초과 페이지 요청 시 빈 contents, hasNext=false를 반환한다 -- [ ] size > 100 요청 시 400 BAD_REQUEST를 반환한다 - [ ] page=-1 요청 시 400 BAD_REQUEST를 반환한다 - [ ] size=0 요청 시 400 BAD_REQUEST를 반환한다 - [ ] type=MUSICAL -> MUSICAL만 조회된다 diff --git a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java index 52f4860..e1fed44 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.NoHandlerFoundException; @Slf4j @@ -35,6 +36,12 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e return new ErrorResponse(BAD_REQUEST, requireNonNull(defaultMessage)); } + @ExceptionHandler(HandlerMethodValidationException.class) + public ErrorResponse handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + return new ErrorResponse(BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(NoHandlerFoundException.class) public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex) { return new ErrorResponse(NOT_FOUND, ex.getMessage()); From b09f94603e9fc81a353f1830cdb2d6793236bc9c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 20:50:26 +0900 Subject: [PATCH 13/46] update show response handling and utility methods in tests --- .../booking/utils/IntegrationTestUtils.java | 24 ++++++------------- .../booking/webapi/show/GET_specs.java | 8 +++---- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index 1cd3185..e8ee086 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -1,6 +1,5 @@ package org.mandarin.booking.utils; -import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.utils.EnumFixture.randomEnum; import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; @@ -28,7 +27,6 @@ import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowRegisterRequest; -import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.venue.Hall; import org.springframework.test.util.ReflectionTestUtils; @@ -134,20 +132,6 @@ public Hall insertDummyHall() { return hallRepository.insert(hall); } - public ShowRegisterResponse generateShow() { - var authToken = getAuthToken(ADMIN); - var request = validShowRegisterRequest(); - - // Act - var response = post( - "/api/show", - request - ) - .withAuthorization(authToken) - .assertSuccess(ShowRegisterResponse.class); - return response.getData(); - } - private ShowRegisterRequest validShowRegisterRequest() { return new ShowRegisterRequest( UUID.randomUUID().toString().substring(0, 10), @@ -160,10 +144,16 @@ private ShowRegisterRequest validShowRegisterRequest() { ); } - public List generateShows() { + public List generateShows() { return IntStream.range(0, 10) .mapToObj(i -> generateShow()) .toList(); } + + private Show generateShow() { + var request = validShowRegisterRequest(); + var show = Show.create(ShowCreateCommand.from(request)); + return showRepository.insert(show); + } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 951107a..aedb3d8 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.adapter.SliceView; -import org.mandarin.booking.domain.show.ShowRegisterResponse; +import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; @@ -85,7 +85,7 @@ public class GET_specs { @Autowired IntegrationTestUtils testUtils ) { // Arrange - List showRegisterResponses = testUtils.generateShows(); + List showRegisterResponses = testUtils.generateShows(); // Act var response = testUtils.get("/api/show") @@ -93,9 +93,9 @@ public class GET_specs { }); // Assert - for (ShowRegisterResponse res : showRegisterResponses) { + for (Show res : showRegisterResponses) { assertThat(response.getData().contents().stream() - .anyMatch(show -> show.showId().equals(res.showId()))) + .anyMatch(show -> show.showId().equals(res.getId()))) .isTrue(); } } From f8ef7080998dddce2d156e0a9615dbe26a8651ac Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 21:02:52 +0900 Subject: [PATCH 14/46] refactor integration tests to use TestFixture for dummy data creation --- .../adapter/security/AuthIntegrationTest.java | 6 +- .../booking/utils/IntegrationTestUtils.java | 110 ++---------------- .../utils/IntegrationTestUtilsSpecs.java | 5 +- .../mandarin/booking/utils/TestConfig.java | 16 ++- .../mandarin/booking/utils/TestFixture.java | 108 +++++++++++++++++ .../booking/webapi/auth/login/POST_specs.java | 27 +++-- .../webapi/auth/reissue/POST_specs.java | 6 +- .../booking/webapi/show/GET_specs.java | 6 +- .../webapi/show/schedule/POST_specs.java | 58 +++++---- 9 files changed, 192 insertions(+), 150 deletions(-) create mode 100644 application/src/test/java/org/mandarin/booking/utils/TestFixture.java diff --git a/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java b/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java index 4503884..705be7f 100644 --- a/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java +++ b/application/src/test/java/org/mandarin/booking/adapter/security/AuthIntegrationTest.java @@ -14,6 +14,7 @@ import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; import org.mandarin.booking.utils.NoRestDocs; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -94,9 +95,10 @@ void failWithInvalidBearer(@Autowired IntegrationTestUtils testUtils) { } @Test - void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils) { + void lackOfAuthorityMustReturnAccessDenied(@Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture) { // Arrange - var member = testUtils.insertDummyMember("dummy", "dummy", List.of()); + var member = testFixture.insertDummyMember("dummy", "dummy", List.of()); var accessToken = testUtils.getAuthToken(member); // Act diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index e8ee086..e29043c 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -1,42 +1,23 @@ package org.mandarin.booking.utils; -import static org.mandarin.booking.utils.EnumFixture.randomEnum; -import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import java.time.LocalDate; import java.util.Collection; import java.util.List; -import java.util.UUID; -import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.TokenHolder; import org.mandarin.booking.adapter.TokenUtils; -import org.mandarin.booking.app.member.MemberCommandRepository; -import org.mandarin.booking.app.show.ShowCommandRepository; -import org.mandarin.booking.app.venue.HallCommandRepository; import org.mandarin.booking.domain.member.Member; -import org.mandarin.booking.domain.member.Member.MemberCreateCommand; -import org.mandarin.booking.domain.member.SecurePasswordEncoder; -import org.mandarin.booking.domain.show.Show; -import org.mandarin.booking.domain.show.Show.Rating; -import org.mandarin.booking.domain.show.Show.ShowCreateCommand; -import org.mandarin.booking.domain.show.Show.Type; -import org.mandarin.booking.domain.show.ShowRegisterRequest; -import org.mandarin.booking.domain.venue.Hall; -import org.springframework.test.util.ReflectionTestUtils; -public record IntegrationTestUtils(MemberCommandRepository memberRepository, - ShowCommandRepository showRepository, - HallCommandRepository hallRepository, - TokenUtils tokenUtils, - SecurePasswordEncoder securePasswordEncoder, - ObjectMapper objectMapper, - DocsUtils docsUtils) { +public record IntegrationTestUtils( + TestFixture fixture, + TokenUtils tokenUtils, + ObjectMapper objectMapper, + DocsUtils docsUtils) { public IntegrationTestUtils { objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } @@ -54,13 +35,13 @@ public TestResult post(String path, T request) { } public String getValidRefreshToken() { - var member = insertDummyMember(generateUserId(), generatePassword()); + var member = fixture.insertDummyMember(generateUserId(), generatePassword()); return tokenUtils.generateToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .refreshToken(); } public String getAuthToken() { - var member = this.insertDummyMember(); + var member = fixture.insertDummyMember(); return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .accessToken(); } @@ -75,85 +56,10 @@ public TokenHolder getUserToken(String userId, String nickname, return tokenUtils.generateToken(userId, nickname, authorities); } - public Member insertDummyMember(String userId, String password) { - var command = new MemberCreateCommand( - generateNickName(), - userId, - password, - generateEmail() - ); - return memberRepository.insert( - Member.create(command, securePasswordEncoder) - ); - } - - public Member insertDummyMember(String userId, String nickName, List authorities) { - var command = new MemberCreateCommand( - nickName, - userId, - generatePassword(), - generateEmail() - ); - var member = Member.create(command, securePasswordEncoder); - ReflectionTestUtils.setField(member, "authorities", authorities); - return memberRepository.insert( - member - ); - } - - public Member insertDummyMember() { - return this.insertDummyMember(generateUserId(), generatePassword()); - } - - public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { - var command = ShowCreateCommand.from( - new ShowRegisterRequest( - UUID.randomUUID().toString().substring(0, 5), - "MUSICAL", - "AGE12", - "synopsis", - "https://example.com/poster.jpg", - performanceStartDate, - performanceEndDate - ) - ); - var show = Show.create(command); - return showRepository.insert(show); - } - public String getAuthToken(MemberAuthority... memberAuthority) { - var member = this.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); + var member = fixture.insertDummyMember(generateUserId(), generateNickName(), List.of(memberAuthority)); return "Bearer " + this.getUserToken(member.getUserId(), member.getNickName(), member.getAuthorities()) .accessToken(); } - - public Hall insertDummyHall() { - var hall = Hall.create(); - return hallRepository.insert(hall); - } - - private ShowRegisterRequest validShowRegisterRequest() { - return new ShowRegisterRequest( - UUID.randomUUID().toString().substring(0, 10), - randomEnum(Type.class).name(), - randomEnum(Rating.class).name(), - "공연 줄거리", - "https://example.com/poster.jpg", - LocalDate.now(), - LocalDate.now().plusDays(30) - ); - } - - public List generateShows() { - return IntStream.range(0, 10) - .mapToObj(i -> generateShow()) - .toList(); - } - - private Show generateShow() { - var request = validShowRegisterRequest(); - var show = Show.create(ShowCreateCommand.from(request)); - return showRepository.insert(show); - } } diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java index 5eff281..7bdba88 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtilsSpecs.java @@ -41,14 +41,15 @@ void post_echo_success( @Test @DisplayName("insertDummyMember로 저장한 회원을 test-only endpoint(/test/member/exists)로 검증한다") void insertDummyMember_and_verify_exists( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange String userId = "it_utils_user" + System.currentTimeMillis(); String password = "P@ssw0rd!"; // save member using utils - var saved = integrationUtils.insertDummyMember(userId, password); + var saved = testFixture.insertDummyMember(userId, password); assertThat(saved).isNotNull(); // Act diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index 0cbd7ad..b394fc7 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -13,14 +13,18 @@ @TestConfiguration public class TestConfig { @Bean - public IntegrationTestUtils integrationTestUtils(@Autowired MemberCommandRepository memberRepository, - @Autowired ShowCommandRepository showRepository, - @Autowired HallCommandRepository hallRepository, + public IntegrationTestUtils integrationTestUtils(@Autowired TestFixture testFixture, @Autowired TokenUtils tokenUtils, - @Autowired SecurePasswordEncoder securePasswordEncoder, @Autowired ObjectMapper objectMapper, @Autowired DocsUtils docsUtils) { - return new IntegrationTestUtils(memberRepository, showRepository, hallRepository, - tokenUtils, securePasswordEncoder, objectMapper, docsUtils); + return new IntegrationTestUtils(testFixture, tokenUtils, objectMapper, docsUtils); + } + + @Bean + public TestFixture testFixture(@Autowired MemberCommandRepository memberRepository, + @Autowired ShowCommandRepository showRepository, + @Autowired HallCommandRepository hallRepository, + @Autowired SecurePasswordEncoder securePasswordEncoder) { + return new TestFixture(memberRepository, showRepository, hallRepository, securePasswordEncoder); } } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java new file mode 100644 index 0000000..d131b0b --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -0,0 +1,108 @@ +package org.mandarin.booking.utils; + +import static org.mandarin.booking.utils.EnumFixture.randomEnum; +import static org.mandarin.booking.utils.MemberFixture.EmailGenerator.generateEmail; +import static org.mandarin.booking.utils.MemberFixture.NicknameGenerator.generateNickName; +import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; +import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; +import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.app.member.MemberCommandRepository; +import org.mandarin.booking.app.show.ShowCommandRepository; +import org.mandarin.booking.app.venue.HallCommandRepository; +import org.mandarin.booking.domain.member.Member; +import org.mandarin.booking.domain.member.Member.MemberCreateCommand; +import org.mandarin.booking.domain.member.SecurePasswordEncoder; +import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.ShowCreateCommand; +import org.mandarin.booking.domain.show.Show.Type; +import org.mandarin.booking.domain.show.ShowRegisterRequest; +import org.mandarin.booking.domain.venue.Hall; +import org.springframework.test.util.ReflectionTestUtils; + +public record TestFixture( + MemberCommandRepository memberRepository, + ShowCommandRepository showRepository, + HallCommandRepository hallRepository, + SecurePasswordEncoder securePasswordEncoder +) { + public Member insertDummyMember(String userId, String password) { + var command = new MemberCreateCommand( + generateNickName(), + userId, + password, + generateEmail() + ); + return memberRepository.insert( + Member.create(command, securePasswordEncoder) + ); + } + + public Member insertDummyMember(String userId, String nickName, List authorities) { + var command = new MemberCreateCommand( + nickName, + userId, + generatePassword(), + generateEmail() + ); + var member = Member.create(command, securePasswordEncoder); + ReflectionTestUtils.setField(member, "authorities", authorities); + return memberRepository.insert( + member + ); + } + + public Member insertDummyMember() { + return this.insertDummyMember(generateUserId(), generatePassword()); + } + + public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { + var command = ShowCreateCommand.from( + new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 5), + "MUSICAL", + "AGE12", + "synopsis", + "https://example.com/poster.jpg", + performanceStartDate, + performanceEndDate + ) + ); + var show = Show.create(command); + return showRepository.insert(show); + } + + public Hall insertDummyHall() { + var hall = Hall.create(); + return hallRepository.insert(hall); + } + + public List generateShows() { + return IntStream.range(0, 10) + .mapToObj(i -> generateShow()) + .toList(); + } + + private Show generateShow() { + var request = validShowRegisterRequest(); + var show = Show.create(ShowCreateCommand.from(request)); + return showRepository.insert(show); + } + + private ShowRegisterRequest validShowRegisterRequest() { + return new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now(), + LocalDate.now().plusDays(30) + ); + } +} diff --git a/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java index 8fe2193..b4a77aa 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java @@ -22,6 +22,7 @@ import org.mandarin.booking.domain.member.AuthRequest; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -30,13 +31,14 @@ public class POST_specs { @Test void 올바른_요청을_보내면_200_OK_상태코드를_반환한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var request = new AuthRequest(generateUserId(), generatePassword()); // save member - integrationUtils.insertDummyMember(request.userId(), request.password()); + testFixture.insertDummyMember(request.userId(), request.password()); // Act var response = integrationUtils.post( @@ -107,7 +109,8 @@ public class POST_specs { @Test void 요청_본문의_password가_userId에_해당하는_password가_일치하지_않으면_401_Unauthorized_상태코드를_반환한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var userId = generateUserId(); @@ -116,7 +119,7 @@ public class POST_specs { var request = new AuthRequest(userId, invalidPassword); //save member - integrationUtils.insertDummyMember(userId, generatePassword()); + testFixture.insertDummyMember(userId, generatePassword()); // Act var response = integrationUtils.post( @@ -131,11 +134,12 @@ public class POST_specs { @Test void 성공적인_로그인_후_응답에_accessToken과_refreshToken가_포함되어야_한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var request = new AuthRequest(generateUserId(), generatePassword()); - integrationUtils.insertDummyMember(request.userId(), request.password()); + testFixture.insertDummyMember(request.userId(), request.password()); // Act var response = integrationUtils.post( @@ -151,12 +155,13 @@ public class POST_specs { @Test void 전달된_토큰은_유효한_JWT_형식이어야_한다( - @Autowired IntegrationTestUtils integrationUtils + @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture ) { // Arrange var userId = generateUserId(); var password = generatePassword(); - integrationUtils.insertDummyMember(userId, password); + testFixture.insertDummyMember(userId, password); var request = new AuthRequest(userId, password); @@ -177,11 +182,12 @@ public class POST_specs { @Test void 전달된_토큰은_만료되지_않아야한다( @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture, @Value("${jwt.token.secret}") String secretKey) { // Arrange var userId = generateUserId(); var password = generatePassword(); - integrationUtils.insertDummyMember(userId, password); + testFixture.insertDummyMember(userId, password); SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes()); var request = new AuthRequest(userId, password); @@ -207,13 +213,14 @@ public class POST_specs { @Test void 전달된_토큰에는_사용자의_userId가_포함되어야_한다( @Autowired IntegrationTestUtils integrationUtils, + @Autowired TestFixture testFixture, @Value("${jwt.token.secret}") String secretKey, @Autowired MemberQueryRepository memberRepository ) { // Arrange var userId = generateUserId(); var password = generatePassword(); - integrationUtils.insertDummyMember(userId, password); + testFixture.insertDummyMember(userId, password); SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes()); var request = new AuthRequest(userId, password); diff --git a/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java index b883a18..8573d22 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/reissue/POST_specs.java @@ -23,6 +23,7 @@ import org.mandarin.booking.domain.member.ReissueRequest; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.test.context.TestPropertySource; @@ -103,10 +104,11 @@ public class POST_specs { @Test void 요청_토큰의_서명이_잘못된_경우_401_Unauthorized가_발생한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - testUtils.insertDummyMember(generateUserId(), generatePassword()); + testFixture.insertDummyMember(generateUserId(), generatePassword()); var invalidRefresh = "invalid_refresh_token"; var request = new ReissueRequest(invalidRefresh); diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index aedb3d8..996835a 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -14,6 +14,7 @@ import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @DisplayName("GET /api/show") @@ -82,10 +83,11 @@ public class GET_specs { @Test void 실제로_저장된_공연_정보가_조회된다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - List showRegisterResponses = testUtils.generateShows(); + List showRegisterResponses = testFixture.generateShows(); // Act var response = testUtils.get("/api/show") diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 580f485..58ef7b8 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -20,6 +20,7 @@ import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -28,11 +29,12 @@ public class POST_specs { @Test void 올바른_접근_토큰과_유효한_요청을_보내면_SUCCESS_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hall = testFixture.insertDummyHall(); var request = generateShowScheduleRegisterRequest( show, requireNonNull(hall.getId()), LocalDateTime.of(2025, 9, 10, 19, 0), @@ -49,11 +51,12 @@ show, requireNonNull(hall.getId()), @Test void ADMIN_권한을_가진_사용자가_올바른_요청을_하는_경우_SUCCESS_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hall = testFixture.insertDummyHall(); var request = generateShowScheduleRegisterRequest( show, requireNonNull(hall.getId()), LocalDateTime.of(2025, 9, 10, 19, 0), @@ -70,11 +73,12 @@ show, requireNonNull(hall.getId()), @Test void 응답_본문에_scheduleId가_포함된다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var hall = testFixture.insertDummyHall(); var request = generateShowScheduleRegisterRequest( show, requireNonNull(hall.getId()), LocalDateTime.of(2025, 9, 10, 19, 0), @@ -91,10 +95,11 @@ show, requireNonNull(hall.getId()), @Test void 권한이_없는_사용자_토큰으로_요청하면_FORBIDDEN_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show); // Act @@ -109,10 +114,11 @@ show, requireNonNull(hall.getId()), @Test void startAt이_endAt보다_늦은_경우_BAD_REQUEST를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 21, 30), LocalDateTime.of(2025, 9, 10, 19, 0), 10L @@ -130,10 +136,11 @@ show, requireNonNull(hall.getId()), @Test void 존재하지_않는_showId를_보내면_NOT_FOUND_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId 10L, @@ -153,10 +160,11 @@ show, requireNonNull(hall.getId()), @Test void 존재하지_않는_hallId를_보내면_NOT_FOUND_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), 9999L,// 존재하지 않는 hallId @@ -176,11 +184,12 @@ show, requireNonNull(hall.getId()), @Test void 공연_기간_범위를_벗어나는_startAt_또는_endAt을_보낼_경우_BAD_REQUEST를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var show = testUtils.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); - var hall = testUtils.insertDummyHall(); + var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); + var hall = testFixture.insertDummyHall(); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), requireNonNull(hall.getId()), @@ -200,12 +209,13 @@ show, requireNonNull(hall.getId()), @Test void 동일한_hallId와_시간이_겹치는_회차를_등록하려_하면_INTERNAL_SERVER_ERROR를_반환한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var hall = testUtils.insertDummyHall(); + var hall = testFixture.insertDummyHall(); - var show = testUtils.insertDummyShow( + var show = testFixture.insertDummyShow( LocalDate.now().minusDays(1), LocalDate.now().plusDays(10) ); @@ -214,7 +224,7 @@ show, requireNonNull(hall.getId()), LocalDateTime.now().plusHours(2) ); - var anotherShow = testUtils.insertDummyShow( + var anotherShow = testFixture.insertDummyShow( LocalDate.now().minusDays(2), LocalDate.now().plusDays(30) ); From 88095d5fd51fd00e4b510accb2914135fe8da1aa Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 21:08:48 +0900 Subject: [PATCH 15/46] update show inquiry handling to enforce request parameter constraints and improve test coverage --- .../booking/adapter/webapi/ShowController.java | 3 ++- .../mandarin/booking/utils/TestFixture.java | 4 ++-- .../booking/webapi/show/GET_specs.java | 18 ++++++++++++++++-- docs/specs/api/show_list_inquiry.md | 14 +++++++------- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index b709d47..fff0abd 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import java.time.LocalDate; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; @@ -24,7 +25,7 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping - SliceView inquire(@RequestParam(required = false) Integer page, + SliceView inquire(@RequestParam(required = false) @Min(0) Integer page, @RequestParam(required = false) @Max(value = 100) Integer size, @RequestParam(required = false) String type, @RequestParam(required = false) String rating, diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index d131b0b..4e2eb97 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -82,8 +82,8 @@ public Hall insertDummyHall() { return hallRepository.insert(hall); } - public List generateShows() { - return IntStream.range(0, 10) + public List generateShows(int showCount) { + return IntStream.range(0, showCount) .mapToObj(i -> generateShow()) .toList(); } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 996835a..5eac2a3 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -87,7 +87,7 @@ public class GET_specs { @Autowired TestFixture testFixture ) { // Arrange - List showRegisterResponses = testFixture.generateShows(); + List showRegisterResponses = testFixture.generateShows(10); // Act var response = testUtils.get("/api/show") @@ -119,7 +119,7 @@ public class GET_specs { } @Test - void size가_100보다_큰_요청_시_400_BAD_REQUEST를_반환한다( + void size가_100보다_큰_요청_시_BAD_REQUEST를_반환한다( @Autowired IntegrationTestUtils testUtils ) { // Arrange @@ -131,4 +131,18 @@ public class GET_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void page가_0보다_작은_요청_시_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?page=-1") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index b61cbc4..8a11f2d 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -103,20 +103,20 @@ - [x] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 - [x] 실제로 저장된 공연 정보가 조회된다 - [x] 초과 페이지 요청 시 빈 contents와 hasNext=false를 반환한다 -- [x] size가 100보다 큰 요청 시 400 BAD_REQUEST를 반환한다 +- [x] size가 100보다 큰 요청 시 BAD_REQUEST를 반환한다 +- [x] page가 0보다 작은 요청 시 BAD_REQUEST를 반환한다 +- [ ] size=0 요청 시 BAD_REQUEST를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] page=-1 요청 시 400 BAD_REQUEST를 반환한다 -- [ ] size=0 요청 시 400 BAD_REQUEST를 반환한다 - [ ] type=MUSICAL -> MUSICAL만 조회된다 - [ ] rating=AGE12 -> AGE12 공연만 조회된다 -- [ ] 부적절한 rating으로 요청하는 경우 400 BAD_REQUEST를 반환한다 +- [ ] 부적절한 rating으로 요청하는 경우 BAD_REQUEST를 반환한다 - [ ] q=라라 -> 제목에 "라라"가 포함된 공연만 조회된다 - [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 - [ ] from=2025-10-01&to=2025-10-31 -> 이 기간과 겹치는 공연만 조회된다 - [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 - [ ] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 -- [ ] type/rating에 허용되지 않는 값 입력 시 400 BAD_REQUEST를 반환한다 -- [ ] from 또는 to 형식이 잘못된 경우 400 BAD_REQUEST를 반환한다 -- [ ] from > to인 경우 400 BAD_REQUEST를 반환한다 +- [ ] type/rating에 허용되지 않는 값 입력 시 BAD_REQUEST를 반환한다 +- [ ] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 +- [ ] from > to인 경우 BAD_REQUEST를 반환한다 From 3a419c3e7bb304e6a3fe17a68a3685ebbc546079 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Fri, 19 Sep 2025 21:10:37 +0900 Subject: [PATCH 16/46] update show inquiry endpoint to enforce minimum size constraint and add corresponding tests --- .../booking/adapter/webapi/ShowController.java | 2 +- .../mandarin/booking/webapi/show/GET_specs.java | 14 ++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index fff0abd..5d32729 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -26,7 +26,7 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping SliceView inquire(@RequestParam(required = false) @Min(0) Integer page, - @RequestParam(required = false) @Max(value = 100) Integer size, + @RequestParam(required = false) @Min(1) @Max(value = 100) Integer size, @RequestParam(required = false) String type, @RequestParam(required = false) String rating, @RequestParam(required = false) String q, diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 5eac2a3..dea5624 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -145,4 +145,18 @@ public class GET_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void size가_1보다_작은_요청_시_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?size=0") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 8a11f2d..f951d33 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -105,7 +105,7 @@ - [x] 초과 페이지 요청 시 빈 contents와 hasNext=false를 반환한다 - [x] size가 100보다 큰 요청 시 BAD_REQUEST를 반환한다 - [x] page가 0보다 작은 요청 시 BAD_REQUEST를 반환한다 -- [ ] size=0 요청 시 BAD_REQUEST를 반환한다 +- [x] size가 1보다 작은 요청 시 BAD_REQUEST를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] type=MUSICAL -> MUSICAL만 조회된다 From dee8feb1be4b2b5421d68206a582070a9f10559c Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 15:01:07 +0900 Subject: [PATCH 17/46] update show inquiry to enforce enum constraints on type and rating parameters, add tests for invalid requests --- .../adapter/webapi/ShowController.java | 7 +++-- .../booking/webapi/show/GET_specs.java | 28 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 14 +++++----- .../mandarin/booking/domain/EnumRequest.java | 2 +- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 5d32729..067164a 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -7,6 +7,9 @@ import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; import org.mandarin.booking.app.show.ShowRegisterer; +import org.mandarin.booking.domain.EnumRequest; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.show.ShowResponse; @@ -27,8 +30,8 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping SliceView inquire(@RequestParam(required = false) @Min(0) Integer page, @RequestParam(required = false) @Min(1) @Max(value = 100) Integer size, - @RequestParam(required = false) String type, - @RequestParam(required = false) String rating, + @RequestParam(required = false) @EnumRequest(Type.class) String type, + @RequestParam(required = false) @EnumRequest(Rating.class) String rating, @RequestParam(required = false) String q, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index dea5624..a9a3e4a 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -159,4 +159,32 @@ public class GET_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void 부적절한_type으로_요청하는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?type=AAA")// invalid type + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } + + @Test + void 부적절한_rating으로_요청하는_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + + // Act + var response = testUtils.get("/api/show?rating=AAA") + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index f951d33..5455693 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -106,17 +106,17 @@ - [x] size가 100보다 큰 요청 시 BAD_REQUEST를 반환한다 - [x] page가 0보다 작은 요청 시 BAD_REQUEST를 반환한다 - [x] size가 1보다 작은 요청 시 BAD_REQUEST를 반환한다 +- [x] 부적절한 type으로 요청하는 경우 BAD_REQUEST를 반환한다 +- [x] 부적절한 rating으로 요청하는 경우 BAD_REQUEST를 반환한다 +- [ ] 지정된 type이 존재한다면 해당 type 공연만 조회된다 +- [ ] 지정된 rating이 존재한다면 해당 rating 공연만 조회된다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] type=MUSICAL -> MUSICAL만 조회된다 -- [ ] rating=AGE12 -> AGE12 공연만 조회된다 -- [ ] 부적절한 rating으로 요청하는 경우 BAD_REQUEST를 반환한다 -- [ ] q=라라 -> 제목에 "라라"가 포함된 공연만 조회된다 -- [ ] 여러 건이 존재할 경우 performanceStartDate ASC -> title ASC 순으로 정렬된다 -- [ ] from=2025-10-01&to=2025-10-31 -> 이 기간과 겹치는 공연만 조회된다 +- [ ] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 +- [ ] 여러 건이 존재할 경우 performanceStartDate ASC, title ASC 순으로 정렬된다 +- [ ] from에서 to까지 기간과 겹치는 공연만 조회된다 - [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 - [ ] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 -- [ ] type/rating에 허용되지 않는 값 입력 시 BAD_REQUEST를 반환한다 - [ ] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [ ] from > to인 경우 BAD_REQUEST를 반환한다 diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java index e824e70..90cda0a 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -7,7 +7,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = EnumRequestValidator.class) public @interface EnumRequest { From 23311b603de170929c5657ff888cf3cc85371451 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 17:06:57 +0900 Subject: [PATCH 18/46] update show inquiry to support nullable enum parameters and enhance test coverage for type and rating filters --- .../adapter/webapi/ShowController.java | 5 +- .../src/main/resources/application-test.yml | 7 +- .../booking/utils/IntegrationTestUtils.java | 8 +- .../mandarin/booking/utils/TestFixture.java | 30 ++++++- .../mandarin/booking/utils/TestResult.java | 12 +-- .../booking/webapi/show/GET_specs.java | 84 ++++++++++++------- docs/specs/api/show_list_inquiry.md | 5 +- .../mandarin/booking/domain/EnumRequest.java | 2 + .../booking/domain/EnumRequestValidator.java | 28 ++++--- 9 files changed, 120 insertions(+), 61 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 067164a..0ce0585 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -28,10 +28,11 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping + @Valid SliceView inquire(@RequestParam(required = false) @Min(0) Integer page, @RequestParam(required = false) @Min(1) @Max(value = 100) Integer size, - @RequestParam(required = false) @EnumRequest(Type.class) String type, - @RequestParam(required = false) @EnumRequest(Rating.class) String rating, + @RequestParam(required = false) @EnumRequest(value = Type.class, nullable = true) String type, + @RequestParam(required = false) @EnumRequest(value = Rating.class, nullable = true) String rating, @RequestParam(required = false) String q, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { diff --git a/application/src/main/resources/application-test.yml b/application/src/main/resources/application-test.yml index 1036f80..0b18bab 100644 --- a/application/src/main/resources/application-test.yml +++ b/application/src/main/resources/application-test.yml @@ -23,7 +23,8 @@ jwt: access: 600000 refresh: 1800000 -#logging: -# level: -# org.springframework.security: TRACE +logging: + level: + org.springframework.security: TRACE + org.springframework.web: TRACE diff --git a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java index e29043c..d40ab3c 100644 --- a/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java +++ b/application/src/test/java/org/mandarin/booking/utils/IntegrationTestUtils.java @@ -22,16 +22,16 @@ public record IntegrationTestUtils( objectMapper.enable(SerializationFeature.INDENT_OUTPUT); } - public TestResult get(String path, Object... requestParams) { - return new TestResult(path, null, requestParams) + public TestResult get(String path) { + return new TestResult(path, null) .setContext(objectMapper) - .setExecutor((p, req, headers, params) -> docsUtils.execute("GET", p, null, headers, params)); + .setExecutor((p, req, headers) -> docsUtils.execute("GET", p, null, headers)); } public TestResult post(String path, T request) { return new TestResult(path, request) .setContext(objectMapper) - .setExecutor((p, req, headers, params) -> docsUtils.execute("POST", p, req, headers, params)); + .setExecutor((p, req, headers) -> docsUtils.execute("POST", p, req, headers)); } public String getValidRefreshToken() { diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 4e2eb97..8e359e3 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -88,17 +88,39 @@ public List generateShows(int showCount) { .toList(); } + public void generateShows(int showCount, Type type) { + IntStream.range(0, showCount) + .forEach(i -> generateShow(type)); + } + + public void generateShows(int showCount, Rating rating) { + IntStream.range(0, showCount) + .forEach(i -> generateShow(rating)); + } + + private void generateShow(Type type) { + var request = validShowRegisterRequest(type.name(), randomEnum(Rating.class).name()); + var show = Show.create(ShowCreateCommand.from(request)); + showRepository.insert(show); + } + + private void generateShow(Rating rating) { + var request = validShowRegisterRequest(randomEnum(Type.class).name(), rating.name()); + var show = Show.create(ShowCreateCommand.from(request)); + showRepository.insert(show); + } + private Show generateShow() { - var request = validShowRegisterRequest(); + var request = validShowRegisterRequest(randomEnum(Type.class).name(), randomEnum(Rating.class).name()); var show = Show.create(ShowCreateCommand.from(request)); return showRepository.insert(show); } - private ShowRegisterRequest validShowRegisterRequest() { + private ShowRegisterRequest validShowRegisterRequest(String type, String rating) { return new ShowRegisterRequest( UUID.randomUUID().toString().substring(0, 10), - randomEnum(Type.class).name(), - randomEnum(Rating.class).name(), + type, + rating, "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), diff --git a/application/src/test/java/org/mandarin/booking/utils/TestResult.java b/application/src/test/java/org/mandarin/booking/utils/TestResult.java index d10e3b5..0af6368 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestResult.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestResult.java @@ -16,15 +16,13 @@ public class TestResult { private final String path; private final Object request; - private final Object[] requestParams; private final Map headers = new HashMap<>(); private Executor executor; private ObjectMapper objectMapper; - public TestResult(String path, Object request, Object... requestParams) { + public TestResult(String path, Object request) { this.path = path; this.request = request; - this.requestParams = requestParams; } public ApiResponse assertSuccess(Class responseType) { @@ -140,9 +138,13 @@ private ErrorResponse readErrorResponse() { } private SuccessResponse<@NonNull T> readSuccessResponse(String raw, TypeReference typeRef) { + if (isErrorEnvelope(raw)) { + fail("Expected SuccessResponse but got ErrorResponse: " + raw); + } try { var inner = objectMapper.getTypeFactory().constructType(typeRef); var wrapper = objectMapper.getTypeFactory().constructParametricType(SuccessResponse.class, inner); + return objectMapper.readValue(raw, wrapper); } catch (JsonProcessingException primary) { try { @@ -164,7 +166,7 @@ private ErrorResponse readErrorResponse() { private String getResponse() { if (executor != null) { try { - return executor.execute(path, request, headers, requestParams); + return executor.execute(path, request, headers); } catch (Exception e) { throw new AssertionError("Request execution failed: " + e.getMessage(), e); } @@ -175,7 +177,7 @@ private String getResponse() { @FunctionalInterface public interface Executor { - String execute(String path, Object request, Map headers, Object... requestParams) + String execute(String path, Object request, Map headers) throws Exception; } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index a9a3e4a..7dc1400 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -1,9 +1,8 @@ package org.mandarin.booking.webapi.show; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatStream; import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; -import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; -import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; @@ -11,6 +10,8 @@ import org.junit.jupiter.api.Test; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.domain.show.Show; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; @@ -21,35 +22,21 @@ @IntegrationTest public class GET_specs { - @Test - void Authorization_헤더가_없더라도_접근하더라도_401_Unauthorized가_발생하지_않는다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - - // Act - var response = testUtils.get("/api/show") - .assertSuccess(Void.class); - - // Assert - assertThat(response.getStatus()).isNotEqualTo(UNAUTHORIZED); - } - - @Test - void 잘못된_토큰이나_만료_토큰을_전달해도_정상_응답을_반환한다( - @Autowired IntegrationTestUtils testUtils - ) { - // Arrange - var wrongToken = "wrong_token"; - - // Act - var response = testUtils.get("/api/show") - .withAuthorization(wrongToken) - .assertSuccess(Void.class); +// @Test +// void Authorization_헤더가_없더라도_접근하더라도_401_Unauthorized가_발생하지_않는다( +// @Autowired IntegrationTestUtils testUtils +// ) { +// // Arrange +// +// // Act +// var response = testUtils.get("/api/show") +// .assertSuccess(new TypeReference>() { +// }); +// +// // Assert +// assertThat(response.getStatus()).isNotEqualTo(UNAUTHORIZED); +// } - // Assert - assertThat(response.getStatus()).isEqualTo(SUCCESS); - } @Test void 기본_요청_시_첫번째_페이지의_10건이_반환된다( @@ -187,4 +174,41 @@ public class GET_specs { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void 지정된_type이_존재한다면_해당_type_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10, Type.MUSICAL); + + // Act + var response = testUtils.get("/api/show?type=MUSICAL") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream()) + .allMatch(show -> show.type().equals(Type.MUSICAL)); + } + + @Test + void 지정된_rating이_존재한다면_해당_rating_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10, Rating.ALL); + + // Act + var response = testUtils.get("/api/show?rating=ALL") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream()) + .allMatch(show -> show.rating().equals(Rating.ALL)); + + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 5455693..3a22263 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -97,7 +97,6 @@ ## 테스트 -- [x] Authorization 헤더가 없더라도 접근하더라도 401 Unauthorized가 발생하지 않는다 - [x] 잘못된 토큰/만료 토큰을 전달해도 정상 응답을 반환한다 - [x] 기본 요청 시 첫번째 페이지의 10건이 반환된다 - [x] 공연이 존재하지 않을 경우 빈 contents, hasNext=false를 반환한다 @@ -108,8 +107,8 @@ - [x] size가 1보다 작은 요청 시 BAD_REQUEST를 반환한다 - [x] 부적절한 type으로 요청하는 경우 BAD_REQUEST를 반환한다 - [x] 부적절한 rating으로 요청하는 경우 BAD_REQUEST를 반환한다 -- [ ] 지정된 type이 존재한다면 해당 type 공연만 조회된다 -- [ ] 지정된 rating이 존재한다면 해당 rating 공연만 조회된다 +- [x] 지정된 type이 존재한다면 해당 type 공연만 조회된다 +- [x] 지정된 rating이 존재한다면 해당 rating 공연만 조회된다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java index 90cda0a..54af983 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -19,4 +19,6 @@ Class[] groups() default {}; Class[] payload() default {}; + + boolean nullable() default false; } diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java index 06e98aa..47d7f19 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java @@ -3,31 +3,39 @@ import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import org.jspecify.annotations.NullUnmarked; - +import org.jspecify.annotations.Nullable; @NullUnmarked public class EnumRequestValidator implements ConstraintValidator { private Class> clazz; private String message; + private boolean nullable; @Override public void initialize(EnumRequest constraintAnnotation) { - clazz = constraintAnnotation.value(); - message = constraintAnnotation.message(); + this.clazz = constraintAnnotation.value(); + this.message = constraintAnnotation.message(); + this.nullable = constraintAnnotation.nullable(); } @Override - public boolean isValid(String s, ConstraintValidatorContext context) { - clazz.getEnumConstants(); - for (Enum enumConstant : clazz.getEnumConstants()) { - if (enumConstant.name().equals(s)) { - return true; + public boolean isValid(@Nullable String value, ConstraintValidatorContext context) { + // nullable=true && 값이 null이면 검증 스킵 + if (value == null) { + return nullable; + } + + Enum[] constants = clazz.getEnumConstants(); + if (constants != null) { + for (Enum e : constants) { + if (e.name().equals(value)) { + return true; + } } } context.disableDefaultConstraintViolation(); - context.buildConstraintViolationWithTemplate(message) - .addConstraintViolation(); + context.buildConstraintViolationWithTemplate(message).addConstraintViolation(); return false; } } From 9a05eb341b444818cdd6592852fc61eb9bbad26d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 21:36:13 +0900 Subject: [PATCH 19/46] update show inquiry tests to filter results by query parameter and enhance test fixture for show generation --- .../mandarin/booking/utils/TestFixture.java | 14 +++++++++++++ .../booking/webapi/show/GET_specs.java | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 8e359e3..07057cb 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Random; import java.util.UUID; import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; @@ -98,6 +99,19 @@ public void generateShows(int showCount, Rating rating) { .forEach(i -> generateShow(rating)); } + public void generateShows(int showCount, String titlePart) { + Random random = new Random(); + IntStream.range(0, showCount) + .forEach(i -> { + var request = validShowRegisterRequest(randomEnum(Type.class).name(), + randomEnum(Rating.class).name()); + var show = Show.create(ShowCreateCommand.from(request)); + ReflectionTestUtils.setField(show, "title", + (char) random.nextInt('a', 'z') + titlePart + (char) random.nextInt('a', 'z')); + showRepository.insert(show); + }); + } + private void generateShow(Type type) { var request = validShowRegisterRequest(type.name(), randomEnum(Rating.class).name()); var show = Show.create(ShowCreateCommand.from(request)); diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 7dc1400..9549a9a 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -211,4 +211,24 @@ public class GET_specs { .allMatch(show -> show.rating().equals(Rating.ALL)); } + + @Test + void q값이_비어있지_않다면_제목에_q가_포함된_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + var titlePart = "titlePart"; + testFixture.generateShows(10, titlePart); + + // Act + var response = testUtils.get("/api/show?q=titlePart") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream().map(ShowResponse::title)) + .allMatch(title -> title.contains(titlePart)); + + } } From e0301b359fe907ca9660e0864b722bf9127d3857 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 21:42:11 +0900 Subject: [PATCH 20/46] update show inquiry to sort results by performanceStartDate DESC and title ASC, enhance documentation for query parameters --- .../booking/app/show/ShowQueryRepository.java | 2 +- .../booking/webapi/show/GET_specs.java | 20 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 4 ++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index a369f7d..93bc37a 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -90,7 +90,7 @@ public SliceView fetch(@Nullable Integer page, show.performanceEndDate)) .from(show) .where(builder) - .orderBy(show.performanceStartDate.asc()) + .orderBy(show.performanceStartDate.desc(), show.title.asc()) .offset((long) pageNo * pageSize) .limit(pageSize + 1) .fetch(); diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 9549a9a..fc60135 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -5,6 +5,7 @@ import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; import com.fasterxml.jackson.core.type.TypeReference; +import java.util.Comparator; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -231,4 +232,23 @@ public class GET_specs { .allMatch(title -> title.contains(titlePart)); } + + @Test + void 여러_건이_존재할_경우_performanceStartDate_DESC_title_ASC_순으로_정렬된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20); + + // Act + var response = testUtils.get("/api/show?size=20") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThatStream(response.getData().contents().stream()) + .isSortedAccordingTo(Comparator.comparing(ShowResponse::performanceStartDate) + .thenComparing(ShowResponse::title)); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 3a22263..773c742 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -109,10 +109,10 @@ - [x] 부적절한 rating으로 요청하는 경우 BAD_REQUEST를 반환한다 - [x] 지정된 type이 존재한다면 해당 type 공연만 조회된다 - [x] 지정된 rating이 존재한다면 해당 rating 공연만 조회된다 +- [x] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 +- [x] 여러 건이 존재할 경우 performanceStartDate DESC, title ASC 순으로 정렬된다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 -- [ ] 여러 건이 존재할 경우 performanceStartDate ASC, title ASC 순으로 정렬된다 - [ ] from에서 to까지 기간과 겹치는 공연만 조회된다 - [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 From 129996f200ca4da85540b196c51f9f9931723922 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 22:27:45 +0900 Subject: [PATCH 21/46] update show inquiry to filter shows by date range, enhance test coverage for date overlap scenarios --- .../mandarin/booking/utils/TestFixture.java | 18 +++++++++++++++ .../booking/webapi/show/GET_specs.java | 22 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 6 ++--- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 07057cb..c637ee1 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -112,6 +112,24 @@ public void generateShows(int showCount, String titlePart) { }); } + public void generateShows(int showCount, int before, int after) { + Random random = new Random(); + IntStream.range(0, showCount) + .forEach(i -> { + var request = new ShowRegisterRequest( + UUID.randomUUID().toString().substring(0, 10), + randomEnum(Type.class).name(), + randomEnum(Rating.class).name(), + "공연 줄거리", + "https://example.com/poster.jpg", + LocalDate.now().minusDays(random.nextInt(before)), + LocalDate.now().plusDays(random.nextInt(after)) + ); + var show = Show.create(ShowCreateCommand.from(request)); + showRepository.insert(show); + }); + } + private void generateShow(Type type) { var request = validShowRegisterRequest(type.name(), randomEnum(Rating.class).name()); var show = Show.create(ShowCreateCommand.from(request)); diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index fc60135..277ade8 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -5,6 +5,7 @@ import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; import com.fasterxml.jackson.core.type.TypeReference; +import java.time.LocalDate; import java.util.Comparator; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -251,4 +252,25 @@ public class GET_specs { .isSortedAccordingTo(Comparator.comparing(ShowResponse::performanceStartDate) .thenComparing(ShowResponse::title)); } + + @Test + void from에서_to까지_기간과_겹치는_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().minusDays(1).toString(); + var to = LocalDate.now().plusDays(1).toString(); + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().stream()) + .allMatch(show -> !show.performanceStartDate().isAfter(LocalDate.parse(to))// 결과의 시작일은 요청 종요일보다 이후가 아니며 + && !show.performanceEndDate() + .isBefore(LocalDate.parse(from)));//결과의 종료일은 요청 시작일보다 이전이 아니다 + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 773c742..ab5b0e9 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -111,11 +111,11 @@ - [x] 지정된 rating이 존재한다면 해당 rating 공연만 조회된다 - [x] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 - [x] 여러 건이 존재할 경우 performanceStartDate DESC, title ASC 순으로 정렬된다 -- [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 -- [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] from에서 to까지 기간과 겹치는 공연만 조회된다 +- [x] from에서 to까지 기간과 겹치는 공연만 조회된다 - [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 +- [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 +- [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 - [ ] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [ ] from > to인 경우 BAD_REQUEST를 반환한다 From d197ef25ccaf4c0781314b1bfd784fc3e9f96bbe Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 22:29:37 +0900 Subject: [PATCH 22/46] update show inquiry tests to verify filtering by 'from' parameter, enhance test coverage for future performance dates --- .../booking/webapi/show/GET_specs.java | 18 ++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 277ade8..fbe8ed7 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -273,4 +273,22 @@ public class GET_specs { && !show.performanceEndDate() .isBefore(LocalDate.parse(from)));//결과의 종료일은 요청 시작일보다 이전이 아니다 } + + @Test + void from만_지정_시_해당_일자_이후_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + + // Act + var response = testUtils.get("/api/show?from=" + LocalDate.now().plusDays(1)) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().stream()) + .allMatch(show -> show.performanceStartDate().isAfter(LocalDate.now().plusDays(1))); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index ab5b0e9..7ed7e29 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -112,7 +112,7 @@ - [x] q값이 비어있지 않다면 제목에 q가 포함된 공연만 조회된다 - [x] 여러 건이 존재할 경우 performanceStartDate DESC, title ASC 순으로 정렬된다 - [x] from에서 to까지 기간과 겹치는 공연만 조회된다 -- [ ] from만 지정 시 해당 일자 이후 공연만 조회된다 +- [x] from만 지정 시 해당 일자 이후 공연만 조회된다 - [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 From 301b89778c9c027bf26ed4719f4d4a8c470f8608 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 22:58:04 +0900 Subject: [PATCH 23/46] update show inquiry tests to verify filtering by 'to' parameter, enhance test fixture for show generation --- .../booking/app/show/ShowQueryRepository.java | 10 +++--- .../mandarin/booking/utils/TestConfig.java | 7 ++++- .../mandarin/booking/utils/TestFixture.java | 30 ++++++++++++++---- .../booking/webapi/show/GET_specs.java | 31 +++++++++++++++++-- docs/specs/api/show_list_inquiry.md | 2 +- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index 93bc37a..416f9b4 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -71,11 +71,12 @@ public SliceView fetch(@Nullable Integer page, builder.and(show.title.containsIgnoreCase(q)); } if (from != null && to != null) { - builder.and(show.performanceStartDate.between(from, to)); + builder.and(show.performanceStartDate.after(from)) + .and(show.performanceEndDate.before(to)); } else if (from != null) { - builder.and(show.performanceStartDate.goe(from)); + builder.and(show.performanceStartDate.after(from)); } else if (to != null) { - builder.and(show.performanceStartDate.loe(to)); + builder.and(show.performanceEndDate.before(to)); } List results = queryFactory @@ -96,9 +97,6 @@ public SliceView fetch(@Nullable Integer page, .fetch(); boolean hasNext = results.size() > pageSize; - if (hasNext) { - results.remove(pageSize); - } return new SliceView<>(results, pageNo, pageSize, hasNext); } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index b394fc7..8f37b2b 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -1,6 +1,8 @@ package org.mandarin.booking.utils; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.mandarin.booking.adapter.TokenUtils; import org.mandarin.booking.app.member.MemberCommandRepository; import org.mandarin.booking.app.show.ShowCommandRepository; @@ -12,6 +14,9 @@ @TestConfiguration public class TestConfig { + @PersistenceContext + private EntityManager entityManager; + @Bean public IntegrationTestUtils integrationTestUtils(@Autowired TestFixture testFixture, @Autowired TokenUtils tokenUtils, @@ -25,6 +30,6 @@ public TestFixture testFixture(@Autowired MemberCommandRepository memberReposito @Autowired ShowCommandRepository showRepository, @Autowired HallCommandRepository hallRepository, @Autowired SecurePasswordEncoder securePasswordEncoder) { - return new TestFixture(memberRepository, showRepository, hallRepository, securePasswordEncoder); + return new TestFixture(entityManager, memberRepository, showRepository, hallRepository, securePasswordEncoder); } } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index c637ee1..9cb8a07 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -6,6 +6,7 @@ import static org.mandarin.booking.utils.MemberFixture.PasswordGenerator.generatePassword; import static org.mandarin.booking.utils.MemberFixture.UserIdGenerator.generateUserId; +import jakarta.persistence.EntityManager; import java.time.LocalDate; import java.util.List; import java.util.Random; @@ -25,13 +26,25 @@ import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.venue.Hall; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +public class TestFixture { + private final EntityManager entityManager; + private final MemberCommandRepository memberRepository; + private final ShowCommandRepository showRepository; + private final HallCommandRepository hallRepository; + private final SecurePasswordEncoder securePasswordEncoder; + + public TestFixture(EntityManager entityManager, MemberCommandRepository memberRepository, + ShowCommandRepository showRepository, HallCommandRepository hallRepository, + SecurePasswordEncoder securePasswordEncoder) { + this.entityManager = entityManager; + this.memberRepository = memberRepository; + this.showRepository = showRepository; + this.hallRepository = hallRepository; + this.securePasswordEncoder = securePasswordEncoder; + } -public record TestFixture( - MemberCommandRepository memberRepository, - ShowCommandRepository showRepository, - HallCommandRepository hallRepository, - SecurePasswordEncoder securePasswordEncoder -) { public Member insertDummyMember(String userId, String password) { var command = new MemberCreateCommand( generateNickName(), @@ -148,6 +161,11 @@ private Show generateShow() { return showRepository.insert(show); } + @Transactional + public void removeShows() { + entityManager.createQuery("DELETE FROM Show ").executeUpdate(); + } + private ShowRegisterRequest validShowRegisterRequest(String type, String rating) { return new ShowRegisterRequest( UUID.randomUUID().toString().substring(0, 10), diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index fbe8ed7..d29e0db 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.util.Comparator; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mandarin.booking.adapter.SliceView; @@ -40,11 +41,18 @@ public class GET_specs { // } + @BeforeEach + void setUp(@Autowired TestFixture testFixture) { + testFixture.removeShows(); + } + @Test void 기본_요청_시_첫번째_페이지의_10건이_반환된다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange + testFixture.generateShows(10); // Act var response = testUtils.get("/api/show") @@ -68,6 +76,7 @@ public class GET_specs { // Assert assertThat(response.getData().hasNext()).isFalse(); + assertThat(response.getData().contents()).isEmpty(); } @Test @@ -103,8 +112,8 @@ public class GET_specs { }); // Assert - assertThat(response.getData().contents()).isEmpty(); assertThat(response.getData().hasNext()).isFalse(); + assertThat(response.getData().contents()).isEmpty(); } @Test @@ -291,4 +300,22 @@ public class GET_specs { assertThat(response.getData().contents().stream()) .allMatch(show -> show.performanceStartDate().isAfter(LocalDate.now().plusDays(1))); } + + @Test + void to만_지정_시_해당_일자_이전_공연만_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + + // Act + var response = testUtils.get("/api/show?to=" + LocalDate.now().minusDays(3)) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents().stream().map(ShowResponse::performanceEndDate)) + .allMatch(date -> date.isBefore(LocalDate.now().minusDays(3))); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 7ed7e29..53d5e91 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -113,7 +113,7 @@ - [x] 여러 건이 존재할 경우 performanceStartDate DESC, title ASC 순으로 정렬된다 - [x] from에서 to까지 기간과 겹치는 공연만 조회된다 - [x] from만 지정 시 해당 일자 이후 공연만 조회된다 -- [ ] to만 지정 시 해당 일자 이전 공연만 조회된다 +- [x] to만 지정 시 해당 일자 이전 공연만 조회된다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 - [ ] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 From 1f21409d4b2badd84bb514aa38c8267d8fb491f4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 23:00:10 +0900 Subject: [PATCH 24/46] update show inquiry tests to verify empty contents for non-overlapping date ranges, enhance documentation for query parameters --- .../booking/webapi/show/GET_specs.java | 20 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index d29e0db..7faf6bf 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -318,4 +318,24 @@ void setUp(@Autowired TestFixture testFixture) { assertThat(response.getData().contents().stream().map(ShowResponse::performanceEndDate)) .allMatch(date -> date.isBefore(LocalDate.now().minusDays(3))); } + + @Test + void 기간이_서로_맞물리지_않는_경우_빈_contents를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().plusDays(11).toString(); + var to = LocalDate.now().plusDays(21).toString(); + + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + assertThat(response.getData().contents()).isEmpty(); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 53d5e91..1ef36cc 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -114,8 +114,8 @@ - [x] from에서 to까지 기간과 겹치는 공연만 조회된다 - [x] from만 지정 시 해당 일자 이후 공연만 조회된다 - [x] to만 지정 시 해당 일자 이전 공연만 조회된다 -- [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 -- [ ] page=1&size=1 -> 두 번째 건이 반환된다 -- [ ] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 +- [x] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 - [ ] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [ ] from > to인 경우 BAD_REQUEST를 반환한다 +- [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 +- [ ] page=1&size=1 -> 두 번째 건이 반환된다 From e77c02bba25d165e0707eaad8bfc3f647bfe0601 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 23:10:45 +0900 Subject: [PATCH 25/46] update show inquiry to return BAD_REQUEST for invalid from or to date formats, enhance tests for date validation --- .../booking/webapi/show/GET_specs.java | 19 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- .../adapter/GlobalExceptionHandler.java | 6 ++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 7faf6bf..0c3450d 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -338,4 +339,22 @@ void setUp(@Autowired TestFixture testFixture) { assertThat(response.getData().hasNext()).isFalse(); assertThat(response.getData().contents()).isEmpty(); } + + @Test + void from_또는_to_형식이_잘못된_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + var to = LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 1ef36cc..1709ad6 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -115,7 +115,7 @@ - [x] from만 지정 시 해당 일자 이후 공연만 조회된다 - [x] to만 지정 시 해당 일자 이전 공연만 조회된다 - [x] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 -- [ ] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 +- [x] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [ ] from > to인 경우 BAD_REQUEST를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 diff --git a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java index e1fed44..1b67fc9 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; @Slf4j @@ -41,6 +42,11 @@ public ErrorResponse handleHandlerMethodValidationException(HandlerMethodValidat return new ErrorResponse(BAD_REQUEST, ex.getMessage()); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { + return new ErrorResponse(BAD_REQUEST, ex.getMessage()); + } + @ExceptionHandler(NoHandlerFoundException.class) public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex) { From d40bbf86504a4b31fc8d4bfd775c0b2ca2de7081 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 23:17:08 +0900 Subject: [PATCH 26/46] update show inquiry to return BAD_REQUEST for from date after to date, enhance tests for date validation --- .../mandarin/booking/app/show/ShowService.java | 3 +++ .../booking/webapi/show/GET_specs.java | 18 ++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index d96bf24..4112bb9 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -56,6 +56,9 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest @Override public SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new ShowException("BAD_REQUEST", "from 는 to 보다 과거만 가능합니다."); + } return queryRepository.fetch(page, size, nullableEnum(Type.class, type), nullableEnum(Rating.class, rating), q, from, to); diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 0c3450d..8eb21ec 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -357,4 +357,22 @@ void setUp(@Autowired TestFixture testFixture) { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void from이_to이후인_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, 10, 10); + var from = LocalDate.now().plusDays(1).toString(); + var to = LocalDate.now().minusDays(1).toString(); + + // Act + var response = testUtils.get("/api/show?from=" + from + "&to=" + to) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 1709ad6..93afadb 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -116,6 +116,6 @@ - [x] to만 지정 시 해당 일자 이전 공연만 조회된다 - [x] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 - [x] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 -- [ ] from > to인 경우 BAD_REQUEST를 반환한다 +- [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 - [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 - [ ] page=1&size=1 -> 두 번째 건이 반환된다 From 3560656babdb9fe870e86a02325425ec1219786a Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sat, 20 Sep 2025 23:22:02 +0900 Subject: [PATCH 27/46] update show inquiry documentation to clarify test cases and expected responses --- docs/specs/api/show_list_inquiry.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 93afadb..0ed1967 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -93,7 +93,7 @@ ``` ----고 +--- ## 테스트 @@ -117,5 +117,3 @@ - [x] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 - [x] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 -- [ ] page=0&size=1 -> 첫 페이지 한 건만 반환한다 -- [ ] page=1&size=1 -> 두 번째 건이 반환된다 From efbf7071197849b52729af74aa91c33eedda320f Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 21 Sep 2025 12:18:44 +0900 Subject: [PATCH 28/46] update show inquiry to return BAD_REQUEST for empty 'q' parameter, enhance tests and documentation --- .../booking/adapter/webapi/ShowController.java | 3 ++- .../mandarin/booking/app/show/ShowService.java | 2 +- .../mandarin/booking/webapi/show/GET_specs.java | 17 +++++++++++++++++ docs/specs/api/show_list_inquiry.md | 1 + 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 0ce0585..eceeebe 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -3,6 +3,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import java.time.LocalDate; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; @@ -33,7 +34,7 @@ SliceView inquire(@RequestParam(required = false) @Min(0) Integer @RequestParam(required = false) @Min(1) @Max(value = 100) Integer size, @RequestParam(required = false) @EnumRequest(value = Type.class, nullable = true) String type, @RequestParam(required = false) @EnumRequest(value = Rating.class, nullable = true) String rating, - @RequestParam(required = false) String q, + @RequestParam(required = false) @NotBlank String q, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { return showFetcher.fetchShows(page, size, type, rating, q, from, to); diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 4112bb9..b03a764 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -61,7 +61,7 @@ public SliceView fetchShows(Integer page, Integer size, String typ } return queryRepository.fetch(page, size, nullableEnum(Type.class, type), nullableEnum(Rating.class, rating), - q, from, to); + q.trim(), from, to); } private void checkDuplicateTitle(String title) { diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 8eb21ec..7be837c 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -375,4 +375,21 @@ void setUp(@Autowired TestFixture testFixture) { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void q가_공백인_경우_BAD_REQUEST를_반환한다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20, "title"); + var q = " "; + + // Act + var response = testUtils.get("/api/show?q=" + q) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 0ed1967..f2a14fc 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -117,3 +117,4 @@ - [x] 기간이 서로 맞물리지 않는 경우 빈 contents를 반환한다 - [x] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 +- [x] q가 공백인 경우 BAD_REQUEST를 반환한다 From b82f15ee268b700b25437e7f902c20003ea61ba8 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 21 Sep 2025 17:25:44 +0900 Subject: [PATCH 29/46] update show inquiry to handle empty 'q' parameter and validate date range, enhance tests for pagination --- .../booking/adapter/webapi/ShowController.java | 3 +-- .../mandarin/booking/app/show/ShowService.java | 8 ++++++-- .../mandarin/booking/webapi/show/GET_specs.java | 17 +++++++++++++++++ docs/specs/api/show_list_inquiry.md | 1 + 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index eceeebe..0ce0585 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -3,7 +3,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import java.time.LocalDate; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; @@ -34,7 +33,7 @@ SliceView inquire(@RequestParam(required = false) @Min(0) Integer @RequestParam(required = false) @Min(1) @Max(value = 100) Integer size, @RequestParam(required = false) @EnumRequest(value = Type.class, nullable = true) String type, @RequestParam(required = false) @EnumRequest(value = Rating.class, nullable = true) String rating, - @RequestParam(required = false) @NotBlank String q, + @RequestParam(required = false) String q, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { return showFetcher.fetchShows(page, size, type, rating, q, from, to); diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index b03a764..0c363b5 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -56,12 +56,16 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest @Override public SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, LocalDate from, LocalDate to) { - if (from.isAfter(to)) { + if ((from != null && to != null) && from.isAfter(to)) { throw new ShowException("BAD_REQUEST", "from 는 to 보다 과거만 가능합니다."); } + String searchQuery = (q == null) ? null : q.trim(); + if (searchQuery != null && searchQuery.isEmpty()) { + throw new ShowException("BAD_REQUEST", "q는 공백일 수 없습니다."); + } return queryRepository.fetch(page, size, nullableEnum(Type.class, type), nullableEnum(Rating.class, rating), - q.trim(), from, to); + searchQuery, from, to); } private void checkDuplicateTitle(String title) { diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 7be837c..31c0510 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -392,4 +392,21 @@ void setUp(@Autowired TestFixture testFixture) { // Assert assertThat(response.getStatus()).isEqualTo(BAD_REQUEST); } + + @Test + void 마지막_페이지에서_hasNext가_거짓으로_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20); + + // Act + var response = testUtils.get("/api/show?page=1") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isFalse(); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index f2a14fc..23d2fe0 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -118,3 +118,4 @@ - [x] from 또는 to 형식이 잘못된 경우 BAD_REQUEST를 반환한다 - [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 - [x] q가 공백인 경우 BAD_REQUEST를 반환한다 +- [x] 마지막 페이지에서 hasNext가 거짓으로 반환된다 From 82427312417309db29d082caa367bddf9f9332f9 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 21 Sep 2025 17:57:29 +0900 Subject: [PATCH 30/46] update show inquiry to set default pagination values, enhance hasNext logic, and improve tests for pagination --- .../booking/adapter/webapi/ShowController.java | 4 ++-- .../booking/app/show/ShowQueryRepository.java | 16 ++++++---------- .../mandarin/booking/webapi/show/GET_specs.java | 17 +++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 ++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index 0ce0585..b98045e 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -29,8 +29,8 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping @Valid - SliceView inquire(@RequestParam(required = false) @Min(0) Integer page, - @RequestParam(required = false) @Min(1) @Max(value = 100) Integer size, + SliceView inquire(@RequestParam(required = false, defaultValue = "0") @Min(0) Integer page, + @RequestParam(required = false, defaultValue = "10") @Min(1) @Max(value = 100) Integer size, @RequestParam(required = false) @EnumRequest(value = Type.class, nullable = true) String type, @RequestParam(required = false) @EnumRequest(value = Rating.class, nullable = true) String rating, @RequestParam(required = false) String q, diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index 416f9b4..e1fd8f8 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -56,10 +56,6 @@ public SliceView fetch(@Nullable Integer page, @Nullable String q, @Nullable LocalDate from, @Nullable LocalDate to) { - - int pageNo = (page != null && page >= 0) ? page : 0; - int pageSize = (size != null && size > 0) ? size : 10; - BooleanBuilder builder = new BooleanBuilder(); if (type != null) { builder.and(show.type.eq(type)); @@ -67,8 +63,8 @@ public SliceView fetch(@Nullable Integer page, if (rating != null) { builder.and(show.rating.eq(rating)); } - if (q != null && !q.isBlank()) { - builder.and(show.title.containsIgnoreCase(q)); + if (q != null) { + builder.and(show.title.like(q)); } if (from != null && to != null) { builder.and(show.performanceStartDate.after(from)) @@ -92,12 +88,12 @@ public SliceView fetch(@Nullable Integer page, .from(show) .where(builder) .orderBy(show.performanceStartDate.desc(), show.title.asc()) - .offset((long) pageNo * pageSize) - .limit(pageSize + 1) + .offset((long) page * size) + .limit(size + 1) .fetch(); - boolean hasNext = results.size() > pageSize; + boolean hasNext = results.size() > size; - return new SliceView<>(results, pageNo, pageSize, hasNext); + return new SliceView<>(results, page, size, hasNext); } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 31c0510..06f98b4 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -409,4 +409,21 @@ void setUp(@Autowired TestFixture testFixture) { // Assert assertThat(response.getData().hasNext()).isFalse(); } + + @Test + void 마지막_페이지가_아닌_경우_hasNext가_참으로_반환된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(20); + + // Act + var response = testUtils.get("/api/show?page=0") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().hasNext()).isTrue(); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 23d2fe0..90e0db1 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -119,3 +119,5 @@ - [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 - [x] q가 공백인 경우 BAD_REQUEST를 반환한다 - [x] 마지막 페이지에서 hasNext가 거짓으로 반환된다 +- [ ] 마지막 페이지가 아닌 경우 hasNext가 참으로 반환된다 +- [ ] venueName은 존재하는 공연장 이름이 조회된다 From b09a608cf08354553feec9cdc5bda018481d0557 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 21 Sep 2025 18:02:45 +0900 Subject: [PATCH 31/46] update EnumRequestValidator to improve validation logic and add tests for enum constraints --- .../booking/domain/EnumRequestValidator.java | 1 - .../domain/EnumRequestValidatorTest.java | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java index 47d7f19..8924669 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequestValidator.java @@ -20,7 +20,6 @@ public void initialize(EnumRequest constraintAnnotation) { @Override public boolean isValid(@Nullable String value, ConstraintValidatorContext context) { - // nullable=true && 값이 null이면 검증 스킵 if (value == null) { return nullable; } diff --git a/domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java b/domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java new file mode 100644 index 0000000..ebee1b3 --- /dev/null +++ b/domain/src/test/java/org/mandarin/booking/domain/EnumRequestValidatorTest.java @@ -0,0 +1,107 @@ +package org.mandarin.booking.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.lang.reflect.Field; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class EnumRequestValidatorBranchTest { + + private static Validator validator; + + @BeforeAll + static void init() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + validator = factory.getValidator(); + } + } + + @Test + void nullAllowed_passes() { + var dto = new DtoNullableTrue(null); + assertThat(validator.validate(dto)).isEmpty(); + } + + @Test + void nullDenied_fails_withDefaultMessage() { + var dto = new DtoNullableFalse(null); + Set> v = validator.validate(dto); + assertThat(v).hasSize(1); + + assertThat(v.iterator().next().getMessage()) + .isEqualTo("커스텀-불일치"); + } + + @Test + void match_passes() { + assertThat(validator.validate(new DtoNullableTrue("A"))).isEmpty(); + assertThat(validator.validate(new DtoNullableTrue("MUSICAL"))).isEmpty(); + } + + @Test + void mismatch_fails_withCustomViolation() { + Set> v = validator.validate(new DtoNullableTrue("musical")); + assertThat(v).hasSize(1); + assertThat(v.iterator().next().getMessage()).isEqualTo("커스텀-불일치"); + } + + @Test + void constantsIsNull_branch() throws Exception { + EnumRequestValidator validator = new EnumRequestValidator(); + + setField(validator, "clazz", String.class); + setField(validator, "message", "ENUM 값이 올바르지 않습니다."); + setField(validator, "nullable", false); + + ConstraintValidatorContext ctx = mock(ConstraintValidatorContext.class); + ConstraintValidatorContext.ConstraintViolationBuilder builder = + mock(ConstraintValidatorContext.ConstraintViolationBuilder.class); + + when(ctx.buildConstraintViolationWithTemplate("ENUM 값이 올바르지 않습니다.")) + .thenReturn(builder); + + boolean result = validator.isValid("ANY", ctx); + + assertThat(result).isFalse(); + verify(ctx).disableDefaultConstraintViolation(); + verify(ctx).buildConstraintViolationWithTemplate("ENUM 값이 올바르지 않습니다."); + verify(builder).addConstraintViolation(); + verifyNoMoreInteractions(ctx, builder); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + enum Sample {A, MUSICAL} + + static class DtoNullableTrue { + @EnumRequest(value = Sample.class, nullable = true, message = "커스텀-불일치") + String v; + + DtoNullableTrue(String v) { + this.v = v; + } + } + + static class DtoNullableFalse { + @EnumRequest(value = Sample.class, message = "커스텀-불일치") + String v; + + DtoNullableFalse(String v) { + this.v = v; + } + } +} From 75dc7e96fe23639377f8f20df1d7a3db193cdfad Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 21 Sep 2025 18:02:52 +0900 Subject: [PATCH 32/46] refactor SecurityConfig to remove unused AuthorizationRequestMatcherConfigurer bean --- ...efaultAuthorizationRequestMatcherConfigurer.java | 13 ------------- .../mandarin/booking/adapter/SecurityConfig.java | 6 ------ 2 files changed, 19 deletions(-) delete mode 100644 internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java diff --git a/internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java b/internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java deleted file mode 100644 index 73b5194..0000000 --- a/internal/src/main/java/org/mandarin/booking/adapter/DefaultAuthorizationRequestMatcherConfigurer.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.mandarin.booking.adapter; - -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; - -class DefaultAuthorizationRequestMatcherConfigurer implements AuthorizationRequestMatcherConfigurer { - @Override - public void authorizeRequests( - AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth - ) { - auth.anyRequest().authenticated(); - } -} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java index 203fa94..cbaf9ea 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SecurityConfig.java @@ -1,7 +1,6 @@ package org.mandarin.booking.adapter; import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; @@ -29,11 +28,6 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } - @Bean - @ConditionalOnMissingBean - public AuthorizationRequestMatcherConfigurer authorizationRequestMatcherConfigurer() { - return new DefaultAuthorizationRequestMatcherConfigurer(); - } @Bean @Order(1) From fe46f32e60bf8f00aeb4be97c6a030635132d2a4 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Sun, 21 Sep 2025 18:03:42 +0900 Subject: [PATCH 33/46] update show inquiry documentation to reflect hasNext logic for non-last pages --- docs/specs/api/show_list_inquiry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 90e0db1..9ec15c9 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -119,5 +119,5 @@ - [x] from이 to이후인 경우 BAD_REQUEST를 반환한다 - [x] q가 공백인 경우 BAD_REQUEST를 반환한다 - [x] 마지막 페이지에서 hasNext가 거짓으로 반환된다 -- [ ] 마지막 페이지가 아닌 경우 hasNext가 참으로 반환된다 +- [x] 마지막 페이지가 아닌 경우 hasNext가 참으로 반환된다 - [ ] venueName은 존재하는 공연장 이름이 조회된다 From ae4be25a65144fa4dc4cfd239c8e4693c1c09b63 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 00:01:32 +0900 Subject: [PATCH 34/46] update show inquiry tests to verify hallName retrieval and enhance documentation --- .../booking/webapi/show/GET_specs.java | 20 +++++++++++++++++++ docs/specs/api/show_list_inquiry.md | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index 06f98b4..ddeb47a 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -426,4 +426,24 @@ void setUp(@Autowired TestFixture testFixture) { // Assert assertThat(response.getData().hasNext()).isTrue(); } + + @Test + void hallName은_존재하는_공연장_이름이_조회된다( + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture + ) { + // Arrange + testFixture.generateShows(10); + + // Act + var response = testUtils.get("/api/show") + .assertSuccess(new TypeReference>() { + }); + + // Assert + assertThat(response.getData().contents()).isNotEmpty(); + assertThatStream(response.getData().contents().stream()) + .allMatch(show -> !show.hallName().equals("null")) + .allMatch(showResponse -> testFixture.existsVenueName(showResponse.hallName())); + } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 9ec15c9..0058de6 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -120,4 +120,4 @@ - [x] q가 공백인 경우 BAD_REQUEST를 반환한다 - [x] 마지막 페이지에서 hasNext가 거짓으로 반환된다 - [x] 마지막 페이지가 아닌 경우 hasNext가 참으로 반환된다 -- [ ] venueName은 존재하는 공연장 이름이 조회된다 +- [x] hallName은 존재하는 공연장 이름이 조회된다 From 81c25288d929429f341f7e563cda1b5fb60d5c9b Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 00:02:20 +0900 Subject: [PATCH 35/46] update show registration to include hallId in requests and improve related logic --- .../booking/app/show/ShowQueryRepository.java | 24 +++---- .../booking/app/show/ShowService.java | 13 ++-- .../mandarin/booking/utils/TestFixture.java | 57 +++++++++++------ .../booking/webapi/show/POST_specs.java | 58 +++++++++++------ .../webapi/show/schedule/POST_specs.java | 62 +++---------------- .../mandarin/booking/domain/EnumRequest.java | 2 +- .../mandarin/booking/domain/show/Show.java | 19 ++++-- .../domain/show/ShowRegisterRequest.java | 3 + .../booking/domain/show/ShowResponse.java | 2 +- .../booking/domain/show/ShowSchedule.java | 7 +-- .../show/ShowScheduleRegisterRequest.java | 1 - .../mandarin/booking/domain/venue/Hall.java | 7 ++- 12 files changed, 130 insertions(+), 125 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index e1fd8f8..3c9a14c 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -1,11 +1,12 @@ package org.mandarin.booking.app.show; +import static com.querydsl.jpa.JPAExpressions.select; import static org.mandarin.booking.domain.show.QShow.show; import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; +import static org.mandarin.booking.domain.venue.QHall.hall; import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDate; import java.time.LocalDateTime; @@ -39,14 +40,13 @@ public Show findById(Long showId) { } public boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime endAt) { - var fetchFirst = queryFactory - .selectOne() - .from(showSchedule) - .where(showSchedule.hallId.eq(hallId)) - .where(showSchedule.startAt.before(endAt)) - .where(showSchedule.endAt.after(startAt)) - .fetchFirst(); - return fetchFirst == null; + return queryFactory + .selectOne() + .from(show) + .leftJoin(show.schedules, showSchedule) + .where(show.hallId.eq(hallId)) + .where(showSchedule.startAt.before(endAt), showSchedule.endAt.after(startAt)) + .fetchFirst() == null; } public SliceView fetch(@Nullable Integer page, @@ -64,7 +64,7 @@ public SliceView fetch(@Nullable Integer page, builder.and(show.rating.eq(rating)); } if (q != null) { - builder.and(show.title.like(q)); + builder.and(show.title.containsIgnoreCase(q)); } if (from != null && to != null) { builder.and(show.performanceStartDate.after(from)) @@ -82,7 +82,9 @@ public SliceView fetch(@Nullable Integer page, show.type, show.rating, show.posterUrl, - Expressions.nullExpression(), // venueName + select(hall.hallName) + .from(hall) + .where(hall.id.eq(show.hallId)), show.performanceStartDate, show.performanceEndDate)) .from(show) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 0c363b5..ce6bd6c 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -29,8 +29,11 @@ public class ShowService implements ShowRegisterer, ShowFetcher { @Override public ShowRegisterResponse register(ShowRegisterRequest request) { + var hallId = request.hallId(); + + hallValidator.checkHallExist(hallId); var command = ShowCreateCommand.from(request); - var show = Show.create(command); + var show = Show.create(hallId, command); checkDuplicateTitle(show.getTitle()); @@ -41,14 +44,11 @@ public ShowRegisterResponse register(ShowRegisterRequest request) { @Override public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest request) { var show = queryRepository.findById(request.showId()); - var hallId = request.hallId(); - - hallValidator.checkHallExist(hallId); - checkConflictSchedule(hallId, request); + checkConflictSchedule(show.getHallId(), request); var command = new ShowScheduleCreateCommand(request.showId(), request.startAt(), request.endAt()); - show.registerSchedule(hallId, command); + show.registerSchedule(command); var saved = commandRepository.insert(show); return new ShowScheduleRegisterResponse(requireNonNull(saved.getId())); } @@ -79,6 +79,5 @@ private void checkConflictSchedule(Long hallId, ShowScheduleRegisterRequest requ throw new ShowException("해당 회차는 이미 공연 스케줄이 등록되어 있습니다."); } } - } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 9cb8a07..796629e 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -76,8 +76,10 @@ public Member insertDummyMember() { } public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanceEndDate) { + var hall = insertDummyHall(); var command = ShowCreateCommand.from( new ShowRegisterRequest( + hall.getId(), UUID.randomUUID().toString().substring(0, 5), "MUSICAL", "AGE12", @@ -87,38 +89,43 @@ public Show insertDummyShow(LocalDate performanceStartDate, LocalDate performanc performanceEndDate ) ); - var show = Show.create(command); + var show = Show.create(hall.getId(), command); return showRepository.insert(show); } public Hall insertDummyHall() { - var hall = Hall.create(); + var hall = Hall.create("hall name"); return hallRepository.insert(hall); } public List generateShows(int showCount) { + var hall = insertDummyHall(); return IntStream.range(0, showCount) - .mapToObj(i -> generateShow()) + .mapToObj(i -> generateShow(hall.getId())) .toList(); } public void generateShows(int showCount, Type type) { + var hall = insertDummyHall(); IntStream.range(0, showCount) - .forEach(i -> generateShow(type)); + .forEach(i -> generateShow(hall.getId(), type)); } public void generateShows(int showCount, Rating rating) { + var hall = insertDummyHall(); IntStream.range(0, showCount) - .forEach(i -> generateShow(rating)); + .forEach(i -> generateShow(hall.getId(), rating)); } public void generateShows(int showCount, String titlePart) { Random random = new Random(); + var hall = insertDummyHall(); IntStream.range(0, showCount) .forEach(i -> { - var request = validShowRegisterRequest(randomEnum(Type.class).name(), + var request = validShowRegisterRequest(hall.getId(), + randomEnum(Type.class).name(), randomEnum(Rating.class).name()); - var show = Show.create(ShowCreateCommand.from(request)); + var show = Show.create(hall.getId(), ShowCreateCommand.from(request)); ReflectionTestUtils.setField(show, "title", (char) random.nextInt('a', 'z') + titlePart + (char) random.nextInt('a', 'z')); showRepository.insert(show); @@ -127,9 +134,12 @@ public void generateShows(int showCount, String titlePart) { public void generateShows(int showCount, int before, int after) { Random random = new Random(); + var hall = insertDummyHall(); + var hallId = hall.getId(); IntStream.range(0, showCount) .forEach(i -> { var request = new ShowRegisterRequest( + hallId, UUID.randomUUID().toString().substring(0, 10), randomEnum(Type.class).name(), randomEnum(Rating.class).name(), @@ -138,27 +148,27 @@ public void generateShows(int showCount, int before, int after) { LocalDate.now().minusDays(random.nextInt(before)), LocalDate.now().plusDays(random.nextInt(after)) ); - var show = Show.create(ShowCreateCommand.from(request)); + var show = Show.create(hallId, ShowCreateCommand.from(request)); showRepository.insert(show); }); } - private void generateShow(Type type) { - var request = validShowRegisterRequest(type.name(), randomEnum(Rating.class).name()); - var show = Show.create(ShowCreateCommand.from(request)); - showRepository.insert(show); + public boolean existsVenueName(String hallName) { + return (entityManager.createQuery("SELECT COUNT(v) FROM Hall v WHERE v.hallName = :hallName") + .setParameter("hallName", hallName) + .getSingleResult() instanceof Long count) && count > 0; } - private void generateShow(Rating rating) { - var request = validShowRegisterRequest(randomEnum(Type.class).name(), rating.name()); - var show = Show.create(ShowCreateCommand.from(request)); + private void generateShow(Long hallId, Type type) { + var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); showRepository.insert(show); } - private Show generateShow() { - var request = validShowRegisterRequest(randomEnum(Type.class).name(), randomEnum(Rating.class).name()); - var show = Show.create(ShowCreateCommand.from(request)); - return showRepository.insert(show); + private void generateShow(Long hallId, Rating rating) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), rating.name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + showRepository.insert(show); } @Transactional @@ -166,8 +176,15 @@ public void removeShows() { entityManager.createQuery("DELETE FROM Show ").executeUpdate(); } - private ShowRegisterRequest validShowRegisterRequest(String type, String rating) { + private Show generateShow(Long hallId) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + return showRepository.insert(show); + } + + private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, String rating) { return new ShowRegisterRequest( + hallId, UUID.randomUUID().toString().substring(0, 10), type, rating, diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 5159b3b..03eedd2 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -18,6 +18,7 @@ import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -25,31 +26,35 @@ public class POST_specs { static List nullOrBlankElementRequests() { + var hallId = 1L; return List.of( - new ShowRegisterRequest("", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), + new ShowRegisterRequest(hallId, "공연 제목", "", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "", "https://example.com/poster.jpg", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "", LocalDate.now(), LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", null, + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + null, LocalDate.now().plusDays(1)), - new ShowRegisterRequest("공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", + new ShowRegisterRequest(hallId, "공연 제목", "MUSICAL", "ALL", "공연 줄거리", "https://example.com/poster.jpg", LocalDate.now(), null) ); } @Test void 올바른_요청을_보내면_status가_SUCCESS이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(); + var request = validShowRegisterRequest(testFixture.insertDummyHall().getId()); // Act var response = testUtils.post( @@ -65,10 +70,12 @@ static List nullOrBlankElementRequests() { @Test void Authorization_헤더에_유효한_accessToken이_없으면_status가_UNAUTHORIZED이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange - var request = validShowRegisterRequest(); + var hallId = testFixture.insertDummyHall().getId(); + var request = validShowRegisterRequest(hallId); // Act var response = testUtils.post( @@ -104,11 +111,14 @@ static List nullOrBlankElementRequests() { @Test void 허용되지_않은_type이면_BAD_REQUEST이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall().getId(); var request = new ShowRegisterRequest( + hallId, "공연 제목", "MOVIE", // invalid type "AGE12", @@ -132,11 +142,13 @@ static List nullOrBlankElementRequests() { @Test void 올바른_요청을_보내면_응답_본문에_showId가_존재한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(); + var hallId = testFixture.insertDummyHall().getId(); + var request = validShowRegisterRequest(hallId); // Act var response = testUtils.post( @@ -152,11 +164,14 @@ static List nullOrBlankElementRequests() { @Test void 공연_시작일은_공연_종료일_이후면_INTERNAL_SERVER_ERROR이다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); + var hallId = testFixture.insertDummyHall().getId(); var request = new ShowRegisterRequest( + hallId, "공연 제목", "MUSICAL", "AGE12", @@ -183,11 +198,12 @@ static List nullOrBlankElementRequests() { @SuppressWarnings("NonAsciiCharacters") @Test void 중복된_제목의_공연을_등록하면_INTERNAL_SERVER_ERROR가_발생한다( - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange var authToken = testUtils.getAuthToken(ADMIN); - var request = validShowRegisterRequest(); + var request = validShowRegisterRequest(testFixture.insertDummyHall().getId()); testUtils.post( "/api/show", request @@ -195,7 +211,7 @@ static List nullOrBlankElementRequests() { .withAuthorization(authToken) .assertSuccess(ShowRegisterResponse.class); - var duplicateTitleRequest = validShowRegisterRequest(request.title()); + var duplicateTitleRequest = validShowRegisterRequest(request.hallId(), request.title()); // Act var response = testUtils.post( @@ -210,8 +226,9 @@ static List nullOrBlankElementRequests() { assertThat(response.getData()).contains("이미 존재하는 공연 이름입니다:"); } - private ShowRegisterRequest validShowRegisterRequest() { + private ShowRegisterRequest validShowRegisterRequest(Long hallId) { return new ShowRegisterRequest( + hallId, UUID.randomUUID().toString().substring(0, 10), "MUSICAL", "AGE12", @@ -222,8 +239,9 @@ private ShowRegisterRequest validShowRegisterRequest() { ); } - private ShowRegisterRequest validShowRegisterRequest(String title) { + private ShowRegisterRequest validShowRegisterRequest(Long hallId, String title) { return new ShowRegisterRequest( + hallId, title, "MUSICAL", "AGE12", diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java index 58ef7b8..aedebdd 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/schedule/POST_specs.java @@ -34,9 +34,8 @@ public class POST_specs { ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testFixture.insertDummyHall(); var request = generateShowScheduleRegisterRequest( - show, requireNonNull(hall.getId()), + show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -56,9 +55,8 @@ show, requireNonNull(hall.getId()), ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testFixture.insertDummyHall(); var request = generateShowScheduleRegisterRequest( - show, requireNonNull(hall.getId()), + show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -78,9 +76,8 @@ show, requireNonNull(hall.getId()), ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var hall = testFixture.insertDummyHall(); var request = generateShowScheduleRegisterRequest( - show, requireNonNull(hall.getId()), + show, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30)); @@ -121,7 +118,7 @@ show, requireNonNull(hall.getId()), var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 21, 30), - LocalDateTime.of(2025, 9, 10, 19, 0), 10L + LocalDateTime.of(2025, 9, 10, 19, 0) ); // Act @@ -143,7 +140,6 @@ show, requireNonNull(hall.getId()), testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); var request = new ShowScheduleRegisterRequest( 9999L,// 존재하지 않는 showId - 10L, LocalDateTime.of(2025, 9, 10, 19, 0), LocalDateTime.of(2025, 9, 10, 21, 30) ); @@ -158,30 +154,6 @@ show, requireNonNull(hall.getId()), assertThat(response.getData()).contains("존재하지 않는 공연입니다."); } - @Test - void 존재하지_않는_hallId를_보내면_NOT_FOUND_상태코드를_반환한다( - @Autowired IntegrationTestUtils testUtils, - @Autowired TestFixture testFixture - ) { - // Arrange - var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 12, 31)); - var request = new ShowScheduleRegisterRequest( - requireNonNull(show.getId()), - 9999L,// 존재하지 않는 hallId - LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30) - ); - - // Act - var response = testUtils.post("/api/show/schedule", request) - .withAuthorization(testUtils.getAuthToken(DISTRIBUTOR)) - .assertFailure(); - - // Assert - assertThat(response.getStatus()).isEqualTo(NOT_FOUND); - assertThat(response.getData()).contains("해당 공연장을 찾을 수 없습니다."); - } - @Test void 공연_기간_범위를_벗어나는_startAt_또는_endAt을_보낼_경우_BAD_REQUEST를_반환한다( @Autowired IntegrationTestUtils testUtils, @@ -189,10 +161,8 @@ show, requireNonNull(hall.getId()), ) { // Arrange var show = testFixture.insertDummyShow(LocalDate.of(2025, 9, 10), LocalDate.of(2025, 9, 11)); - var hall = testFixture.insertDummyHall(); var request = new ShowScheduleRegisterRequest( requireNonNull(show.getId()), - requireNonNull(hall.getId()), LocalDateTime.of(2023, 9, 10, 19, 0), LocalDateTime.of(2023, 9, 10, 21, 30) ); @@ -213,22 +183,18 @@ show, requireNonNull(hall.getId()), @Autowired TestFixture testFixture ) { // Arrange - var hall = testFixture.insertDummyHall(); - var show = testFixture.insertDummyShow( LocalDate.now().minusDays(1), LocalDate.now().plusDays(10) ); - var request = generateShowScheduleRegisterRequest(show, requireNonNull(hall.getId()), + var request = generateShowScheduleRegisterRequest( + show, LocalDateTime.now(), LocalDateTime.now().plusHours(2) ); - var anotherShow = testFixture.insertDummyShow( - LocalDate.now().minusDays(2), - LocalDate.now().plusDays(30) - ); - var nextRequest = generateShowScheduleRegisterRequest(anotherShow, hall.getId(), + var nextRequest = generateShowScheduleRegisterRequest( + show, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(3) ); @@ -255,23 +221,15 @@ show, requireNonNull(hall.getId()), private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show) { return generateShowScheduleRegisterRequest(show, LocalDateTime.of(2025, 9, 10, 19, 0), - LocalDateTime.of(2025, 9, 10, 21, 30), 10L); + LocalDateTime.of(2025, 9, 10, 21, 30)); } private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, - long hallId, LocalDateTime startAt, LocalDateTime endAt) { - return generateShowScheduleRegisterRequest(show, startAt, endAt, hallId); - } - - private ShowScheduleRegisterRequest generateShowScheduleRegisterRequest(Show show, - LocalDateTime startAt, - LocalDateTime endAt, long hallId) { return new ShowScheduleRegisterRequest( - requireNonNull(show.getId()), - hallId, + show.getId(), startAt, endAt ); diff --git a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java index 54af983..c6754ea 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/EnumRequest.java @@ -14,7 +14,7 @@ Class> value(); - String message() default "invalid value, must be one of valid enum types"; + String message() default "invalid value, must be one from valid enum types"; Class[] groups() default {}; diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java index d5970a0..e35291a 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/Show.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/Show.java @@ -24,19 +24,29 @@ public class Show extends AbstractEntity { @OneToMany(mappedBy = "show", fetch = LAZY, cascade = MERGE) private final List schedules = new ArrayList<>(); + + private Long hallId; + private String title; + @Enumerated(EnumType.STRING) private Type type; + @Enumerated(EnumType.STRING) private Rating rating; + private String synopsis; + private String posterUrl; + private LocalDate performanceStartDate; + private LocalDate performanceEndDate; - private Show(String title, Type type, Rating rating, String synopsis, String posterUrl, + private Show(Long hallId, String title, Type type, Rating rating, String synopsis, String posterUrl, LocalDate performanceStartDate, LocalDate performanceEndDate) { + this.hallId = hallId; this.title = title; this.type = type; this.rating = rating; @@ -46,16 +56,16 @@ private Show(String title, Type type, Rating rating, String synopsis, String pos this.performanceEndDate = performanceEndDate; } - public void registerSchedule(Long hallId, ShowScheduleCreateCommand command) { + public void registerSchedule(ShowScheduleCreateCommand command) { if (!isInSchedule(command.startAt(), command.endAt())) { throw new ShowException("BAD_REQUEST", "공연 기간 범위를 벗어나는 일정입니다."); } - var schedule = ShowSchedule.create(this, hallId, command); + var schedule = ShowSchedule.create(this, command); this.schedules.add(schedule); } - public static Show create(ShowCreateCommand command) { + public static Show create(Long hallId, ShowCreateCommand command) { var startDate = command.getPerformanceStartDate(); var endDate = command.getPerformanceEndDate(); @@ -64,6 +74,7 @@ public static Show create(ShowCreateCommand command) { } return new Show( + hallId, command.getTitle(), command.getType(), command.getRating(), diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index 0c6c920..7810fa7 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -9,6 +9,9 @@ import org.mandarin.booking.domain.show.Show.Type; public record ShowRegisterRequest( + @NotNull(message = "venue id is required") + Long hallId, + @NotBlank(message = "title is required") String title, diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java index 8088a21..fe6f251 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowResponse.java @@ -11,7 +11,7 @@ public record ShowResponse( Type type, Rating rating, String posterUrl, - String venueName, + String hallName, LocalDate performanceStartDate, LocalDate performanceEndDate ) { diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java index a56a915..9dfeea4 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowSchedule.java @@ -20,8 +20,6 @@ class ShowSchedule extends AbstractEntity { @JoinColumn(name = "show_id", nullable = false) private Show show; - private Long hallId; - private LocalDateTime startAt; private LocalDateTime endAt; @@ -30,22 +28,19 @@ class ShowSchedule extends AbstractEntity { private ShowSchedule( Show show, - Long hallId, LocalDateTime startAt, LocalDateTime endAt, Integer runtimeMinutes ) { this.show = show; - this.hallId = hallId; this.startAt = startAt; this.endAt = endAt; this.runtimeMinutes = runtimeMinutes; } - static ShowSchedule create(Show show, Long hallId, ShowScheduleCreateCommand command) { + static ShowSchedule create(Show show, ShowScheduleCreateCommand command) { return new ShowSchedule( show, - hallId, command.startAt(), command.endAt(), command.getRuntimeMinutes() diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java index 05e9942..7b7ba25 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowScheduleRegisterRequest.java @@ -5,7 +5,6 @@ public record ShowScheduleRegisterRequest( Long showId, - Long hallId, LocalDateTime startAt, LocalDateTime endAt ) { diff --git a/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java index 6d508c4..d15a5f6 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java @@ -2,6 +2,7 @@ import jakarta.persistence.Entity; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.mandarin.booking.domain.AbstractEntity; @@ -9,9 +10,11 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { + private String hallName; - public static Hall create() { - return new Hall(); + public static Hall create(String hallName) { + return new Hall(hallName); } } From 6d56a457e505cef9db528f7dd0a110cc39b7bf19 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 00:07:33 +0900 Subject: [PATCH 36/46] remove hallId from show schedule registration requests and update related documentation --- docs/specs/api/show_schedule_register.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/specs/api/show_schedule_register.md b/docs/specs/api/show_schedule_register.md index 31a990d..933a655 100644 --- a/docs/specs/api/show_schedule_register.md +++ b/docs/specs/api/show_schedule_register.md @@ -14,7 +14,6 @@ ```json { "showId": 1, - "hallId": 10, "startAt": "2025-10-10T19:00:00", "endAt": "2025-10-10T21:30:00", "runtimeMinutes": 150 @@ -29,7 +28,6 @@ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ -d '{ "showId": 1, - "hallId": 1, "startAt": "2025-10-10T19:00:00", "endAt": "2025-10-10T21:30:00" }' @@ -60,6 +58,5 @@ - [x] runtimeMinutes은 startAt과 endAt의 차이만큼이 아니면 BAD_REQUEST를 반환한다 - [x] startAt이 endAt보다 늦은 경우 BAD_REQUEST를 반환한다 - [x] 존재하지 않는 showId를 보내면 NOT_FOUND 상태코드를 반환한다 -- [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 - [x] 공연 기간 범위를 벗어나는 startAt 또는 endAt을 보낼 경우 BAD_REQUEST를 반환한다 - [x] 동일한 hallId와 시간이 겹치는 회차를 등록하려 하면 INTERNAL_SERVER_ERROR를 반환한다 From e25815d69b1bbfe2b8f1ea4bb124fcf0e419b549 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 00:07:47 +0900 Subject: [PATCH 37/46] add test for handling non-existent hallId and update documentation --- .../booking/webapi/show/POST_specs.java | 22 +++++++++++++++++++ docs/specs/api/show_register.md | 3 +++ 2 files changed, 25 insertions(+) diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java index 03eedd2..51c7b44 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/POST_specs.java @@ -4,6 +4,7 @@ import static org.mandarin.booking.MemberAuthority.ADMIN; import static org.mandarin.booking.adapter.ApiStatus.BAD_REQUEST; import static org.mandarin.booking.adapter.ApiStatus.INTERNAL_SERVER_ERROR; +import static org.mandarin.booking.adapter.ApiStatus.NOT_FOUND; import static org.mandarin.booking.adapter.ApiStatus.SUCCESS; import static org.mandarin.booking.adapter.ApiStatus.UNAUTHORIZED; @@ -226,6 +227,27 @@ static List nullOrBlankElementRequests() { assertThat(response.getData()).contains("이미 존재하는 공연 이름입니다:"); } + @Test + void 존재하지_않는_hallId를_보내면_NOT_FOUND_상태코드를_반환한다( + @Autowired IntegrationTestUtils testUtils + ) { + // Arrange + var authToken = testUtils.getAuthToken(ADMIN); + var hallId = 1000L; + var request = validShowRegisterRequest(hallId); + + // Act + var response = testUtils.post( + "/api/show", + request + ) + .withAuthorization(authToken) + .assertFailure(); + + // Assert + assertThat(response.getStatus()).isEqualTo(NOT_FOUND); + } + private ShowRegisterRequest validShowRegisterRequest(Long hallId) { return new ShowRegisterRequest( hallId, diff --git a/docs/specs/api/show_register.md b/docs/specs/api/show_register.md index be7c2c3..cc0858a 100644 --- a/docs/specs/api/show_register.md +++ b/docs/specs/api/show_register.md @@ -13,6 +13,7 @@ ```json { + "hallId": 1, "title": "인셉션", "type": "MUSICAL", "rating": "AGE12", @@ -31,6 +32,7 @@ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzNCIsInJvbGVzIjoiUk9MRV9BRE1JTiIsInVzZXJJZCI6InRlc3QxMjM0Iiwibmlja05hbWUiOiJ0ZXN0IiwiaWF0IjoxNzU3MzExNDc5LCJleHAiOjE3NTczMTIwNzl9.xhEkuZEF0gZlvyX_F2kiAMEMGw_C2ZtGL8PmzLxhZQW32A9hmr6M0nauYEejXOFrZAb3nMdU3jFLxuhDWDbE2g' \ -H 'Content-Type: application/json' \ -d '{ + "hallId": 1, "title": "인셉션", "type": "MUSICAL", "rating": "AGE12", @@ -65,3 +67,4 @@ - [x] 올바른 요청을 보내면 응답 본문에 showId가 존재한다 - [x] 공연 시작일은 공연 종료일 이후면 INTERNAL_SERVER_ERROR이다 - [x] 중복된 제목의 공연을 등록하면 INTERNAL_SERVER_ERROR가 발생한다 +- [x] 존재하지 않는 hallId를 보내면 NOT_FOUND 상태코드를 반환한다 From 7847e05b7e92147efa32c41ee41ee0c2cd1e5531 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 00:29:27 +0900 Subject: [PATCH 38/46] refactor show inquiry endpoint to use ShowInquiryRequest for improved parameter handling --- .../adapter/webapi/ShowController.java | 20 ++------- .../domain/show/ShowInquiryRequest.java | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java diff --git a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java index b98045e..a1df9aa 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java +++ b/application/src/main/java/org/mandarin/booking/adapter/webapi/ShowController.java @@ -1,26 +1,19 @@ package org.mandarin.booking.adapter.webapi; import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.time.LocalDate; import org.mandarin.booking.adapter.SliceView; import org.mandarin.booking.app.show.ShowFetcher; import org.mandarin.booking.app.show.ShowRegisterer; -import org.mandarin.booking.domain.EnumRequest; -import org.mandarin.booking.domain.show.Show.Rating; -import org.mandarin.booking.domain.show.Show.Type; +import org.mandarin.booking.domain.show.ShowInquiryRequest; import org.mandarin.booking.domain.show.ShowRegisterRequest; import org.mandarin.booking.domain.show.ShowRegisterResponse; import org.mandarin.booking.domain.show.ShowResponse; import org.mandarin.booking.domain.show.ShowScheduleRegisterRequest; import org.mandarin.booking.domain.show.ShowScheduleRegisterResponse; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -28,15 +21,8 @@ record ShowController(ShowRegisterer showRegisterer, ShowFetcher showFetcher) { @GetMapping - @Valid - SliceView inquire(@RequestParam(required = false, defaultValue = "0") @Min(0) Integer page, - @RequestParam(required = false, defaultValue = "10") @Min(1) @Max(value = 100) Integer size, - @RequestParam(required = false) @EnumRequest(value = Type.class, nullable = true) String type, - @RequestParam(required = false) @EnumRequest(value = Rating.class, nullable = true) String rating, - @RequestParam(required = false) String q, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { - return showFetcher.fetchShows(page, size, type, rating, q, from, to); + SliceView inquire(@Valid ShowInquiryRequest req) { + return showFetcher.fetchShows(req.page(), req.size(), req.type(), req.rating(), req.q(), req.from(), req.to()); } @PostMapping diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java new file mode 100644 index 0000000..cb1f909 --- /dev/null +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java @@ -0,0 +1,42 @@ +package org.mandarin.booking.domain.show; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDate; +import org.jspecify.annotations.Nullable; +import org.mandarin.booking.domain.EnumRequest; +import org.mandarin.booking.domain.show.Show.Rating; +import org.mandarin.booking.domain.show.Show.Type; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; + +public record ShowInquiryRequest( + @Min(0) @Nullable + Integer page, + + @Min(1) @Nullable @Max(100) + Integer size, + + @EnumRequest(value = Type.class, nullable = true) + String type, + + @EnumRequest(value = Rating.class, nullable = true) + String rating, + + String q, + + @DateTimeFormat(iso = ISO.DATE) + LocalDate from, + + @DateTimeFormat(iso = ISO.DATE) + LocalDate to +) { + public ShowInquiryRequest { + if (page == null) { + page = 0; + } + if (size == null) { + size = 10; + } + } +} From 451a47f7ad05f23d4b521d49aaa654e425838978 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 00:35:33 +0900 Subject: [PATCH 39/46] enhance ShowInquiryRequest validation and simplify fetchShows method --- .../org/mandarin/booking/app/show/ShowService.java | 13 +++---------- .../booking/domain/show/ShowInquiryRequest.java | 11 +++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index ce6bd6c..799b43f 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -54,18 +54,11 @@ public ShowScheduleRegisterResponse registerSchedule(ShowScheduleRegisterRequest } @Override - public SliceView fetchShows(Integer page, Integer size, String type, String rating, String q, - LocalDate from, LocalDate to) { - if ((from != null && to != null) && from.isAfter(to)) { - throw new ShowException("BAD_REQUEST", "from 는 to 보다 과거만 가능합니다."); - } - String searchQuery = (q == null) ? null : q.trim(); - if (searchQuery != null && searchQuery.isEmpty()) { - throw new ShowException("BAD_REQUEST", "q는 공백일 수 없습니다."); - } + public SliceView fetchShows(Integer page, Integer size, String type, String rating, + String q, LocalDate from, LocalDate to) { return queryRepository.fetch(page, size, nullableEnum(Type.class, type), nullableEnum(Rating.class, rating), - searchQuery, from, to); + q, from, to); } private void checkDuplicateTitle(String title) { diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java index cb1f909..bcfec70 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowInquiryRequest.java @@ -23,12 +23,15 @@ public record ShowInquiryRequest( @EnumRequest(value = Rating.class, nullable = true) String rating, + @Nullable String q, @DateTimeFormat(iso = ISO.DATE) + @Nullable LocalDate from, @DateTimeFormat(iso = ISO.DATE) + @Nullable LocalDate to ) { public ShowInquiryRequest { @@ -38,5 +41,13 @@ public record ShowInquiryRequest( if (size == null) { size = 10; } + + if ((from != null && to != null) && from.isAfter(to)) { + throw new ShowException("BAD_REQUEST", "from 는 to 보다 과거만 가능합니다."); + } + q = (q == null) ? null : q.trim(); + if (q != null && q.isEmpty()) { + throw new ShowException("BAD_REQUEST", "q는 공백일 수 없습니다."); + } } } From f5598366e87b6783c4ddb6f4bf4666005f3a747d Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 08:32:55 +0900 Subject: [PATCH 40/46] refactor venue to hall terminology and update related classes and documentation --- .../HallCommandRepository.java | 4 +-- .../{venue => hall}/HallQueryRepository.java | 2 +- .../app/{venue => hall}/HallRepository.java | 4 +-- .../app/{venue => hall}/HallService.java | 4 +-- .../app/{venue => hall}/HallValidator.java | 2 +- .../booking/app/hall/package-info.java | 4 +++ .../booking/app/show/ShowQueryRepository.java | 4 +-- .../booking/app/show/ShowService.java | 2 +- .../booking/app/venue/package-info.java | 4 --- .../mandarin/booking/utils/TestConfig.java | 2 +- .../mandarin/booking/utils/TestFixture.java | 10 +++--- .../booking/webapi/show/GET_specs.java | 2 +- docs/specs/api/show_list_inquiry.md | 4 +-- docs/specs/domain.md | 33 ++----------------- docs/todo.md | 2 -- .../booking/domain/{venue => hall}/Hall.java | 8 ++--- .../domain/{venue => hall}/HallException.java | 2 +- .../domain/show/ShowRegisterRequest.java | 2 +- 18 files changed, 32 insertions(+), 63 deletions(-) rename application/src/main/java/org/mandarin/booking/app/{venue => hall}/HallCommandRepository.java (81%) rename application/src/main/java/org/mandarin/booking/app/{venue => hall}/HallQueryRepository.java (91%) rename application/src/main/java/org/mandarin/booking/app/{venue => hall}/HallRepository.java (67%) rename application/src/main/java/org/mandarin/booking/app/{venue => hall}/HallService.java (82%) rename application/src/main/java/org/mandarin/booking/app/{venue => hall}/HallValidator.java (64%) create mode 100644 application/src/main/java/org/mandarin/booking/app/hall/package-info.java delete mode 100644 application/src/main/java/org/mandarin/booking/app/venue/package-info.java rename domain/src/main/java/org/mandarin/booking/domain/{venue => hall}/Hall.java (70%) rename domain/src/main/java/org/mandarin/booking/domain/{venue => hall}/HallException.java (82%) diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java similarity index 81% rename from application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java index 5f18b0f..d05acce 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java @@ -1,7 +1,7 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.venue.Hall; +import org.mandarin.booking.domain.hall.Hall; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java similarity index 91% rename from application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java index 34b4b40..71e40ba 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java similarity index 67% rename from application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java index a1bd6bd..66a25c5 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallRepository.java @@ -1,6 +1,6 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; -import org.mandarin.booking.domain.venue.Hall; +import org.mandarin.booking.domain.hall.Hall; import org.springframework.data.repository.Repository; interface HallRepository extends Repository { diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallService.java b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java similarity index 82% rename from application/src/main/java/org/mandarin/booking/app/venue/HallService.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallService.java index 113519d..ce32723 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallService.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallService.java @@ -1,7 +1,7 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.venue.HallException; +import org.mandarin.booking.domain.hall.HallException; import org.springframework.stereotype.Service; @Service diff --git a/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java similarity index 64% rename from application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java rename to application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java index 001bba6..869d9f2 100644 --- a/application/src/main/java/org/mandarin/booking/app/venue/HallValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallValidator.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.app.venue; +package org.mandarin.booking.app.hall; public interface HallValidator { void checkHallExist(Long hallId); diff --git a/application/src/main/java/org/mandarin/booking/app/hall/package-info.java b/application/src/main/java/org/mandarin/booking/app/hall/package-info.java new file mode 100644 index 0000000..b24b425 --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/hall/package-info.java @@ -0,0 +1,4 @@ +@NamedInterface("hall") +package org.mandarin.booking.app.hall; + +import org.springframework.modulith.NamedInterface; diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index 3c9a14c..bb7b08b 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -2,9 +2,9 @@ import static com.querydsl.jpa.JPAExpressions.select; +import static org.mandarin.booking.domain.hall.QHall.hall; import static org.mandarin.booking.domain.show.QShow.show; import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; -import static org.mandarin.booking.domain.venue.QHall.hall; import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -82,7 +82,7 @@ public SliceView fetch(@Nullable Integer page, show.type, show.rating, show.posterUrl, - select(hall.hallName) + select(hall.name) .from(hall) .where(hall.id.eq(show.hallId)), show.performanceStartDate, diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index 799b43f..e42ff4b 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -6,7 +6,7 @@ import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.mandarin.booking.adapter.SliceView; -import org.mandarin.booking.app.venue.HallValidator; +import org.mandarin.booking.app.hall.HallValidator; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.Rating; import org.mandarin.booking.domain.show.Show.ShowCreateCommand; diff --git a/application/src/main/java/org/mandarin/booking/app/venue/package-info.java b/application/src/main/java/org/mandarin/booking/app/venue/package-info.java deleted file mode 100644 index 43a5843..0000000 --- a/application/src/main/java/org/mandarin/booking/app/venue/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -@NamedInterface("venue") -package org.mandarin.booking.app.venue; - -import org.springframework.modulith.NamedInterface; diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index 8f37b2b..c8e2b25 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -4,9 +4,9 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.mandarin.booking.adapter.TokenUtils; +import org.mandarin.booking.app.hall.HallCommandRepository; import org.mandarin.booking.app.member.MemberCommandRepository; import org.mandarin.booking.app.show.ShowCommandRepository; -import org.mandarin.booking.app.venue.HallCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 796629e..436ecff 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -13,9 +13,10 @@ import java.util.UUID; import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; +import org.mandarin.booking.app.hall.HallCommandRepository; import org.mandarin.booking.app.member.MemberCommandRepository; import org.mandarin.booking.app.show.ShowCommandRepository; -import org.mandarin.booking.app.venue.HallCommandRepository; +import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -24,7 +25,6 @@ import org.mandarin.booking.domain.show.Show.ShowCreateCommand; import org.mandarin.booking.domain.show.Show.Type; import org.mandarin.booking.domain.show.ShowRegisterRequest; -import org.mandarin.booking.domain.venue.Hall; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; @@ -153,9 +153,9 @@ public void generateShows(int showCount, int before, int after) { }); } - public boolean existsVenueName(String hallName) { - return (entityManager.createQuery("SELECT COUNT(v) FROM Hall v WHERE v.hallName = :hallName") - .setParameter("hallName", hallName) + public boolean existsHallName(String name) { + return (entityManager.createQuery("SELECT COUNT(h) FROM Hall h WHERE h.name = :name") + .setParameter("name", name) .getSingleResult() instanceof Long count) && count > 0; } diff --git a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java index ddeb47a..ba6b4b1 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/show/GET_specs.java @@ -444,6 +444,6 @@ void setUp(@Autowired TestFixture testFixture) { assertThat(response.getData().contents()).isNotEmpty(); assertThatStream(response.getData().contents().stream()) .allMatch(show -> !show.hallName().equals("null")) - .allMatch(showResponse -> testFixture.existsVenueName(showResponse.hallName())); + .allMatch(showResponse -> testFixture.existsHallName(showResponse.hallName())); } } diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 0058de6..92b11b4 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -69,7 +69,7 @@ "type": "MUSICAL", "rating": "ALL", "posterUrl": "https://example.com/posters/lalaland.jpg", - "venueName": "샤롯데씨어터", + "hallName": "샤롯데씨어터", "performanceStartDate": "2025-10-05", "performanceEndDate": "2025-11-05" }, @@ -79,7 +79,7 @@ "type": "MUSICAL", "rating": "AGE12", "posterUrl": "https://example.com/posters/lalaland2.jpg", - "venueName": "샤롯데씨어터", + "hallName": "샤롯데씨어터", "performanceStartDate": "2025-10-10", "performanceEndDate": "2025-11-10" } diff --git a/docs/specs/domain.md b/docs/specs/domain.md index cab60ce..72f4ec7 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -76,34 +76,13 @@ _Entity_ --- -## 공연장(Venue) - -_Aggregate Root_ - -- 공연 시설 - -#### 속성 - -- 이름(name) -- 주소(address) - -#### 행위 - -- `create(show,hallId,command)` - -#### 관련 타입 - ---- - ### 홀(Hall) -_Entity_ +- 공연 시설 -- 공연장 내부의 개별 공간 #### 속성 -- venueId(FK) - 이름(name) --- @@ -300,8 +279,7 @@ erDiagram ShowSchedule ||--o{ Casting : has %% UNIQUE(scheduleId, roleName) on Casting -%% Venue AR (Venue 내부에 Hall/Seat/TicketGrade) - Venue ||--o{ Hall : has +%% Hall AR (Hall 내부에 Seat/TicketGrade) Hall ||--o{ Seat : has Hall ||--o{ TicketGrade : has @@ -353,15 +331,8 @@ erDiagram %% UNIQUE(schedule_id, role_name) } - Venue { - BIGINT id PK - string name - string address - } - Hall { BIGINT id PK - BIGINT venueId FK string name } diff --git a/docs/todo.md b/docs/todo.md index cd8330d..1e8ad1c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -29,6 +29,4 @@ --- -- [ ] venue register - [ ] hall register -- [ ] 누가 AR인가? venue vs hall diff --git a/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java similarity index 70% rename from domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java rename to domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java index d15a5f6..7c74e77 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/venue/Hall.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/Hall.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.domain.venue; +package org.mandarin.booking.domain.hall; import jakarta.persistence.Entity; import lombok.AccessLevel; @@ -12,9 +12,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Hall extends AbstractEntity { - private String hallName; + private String name; - public static Hall create(String hallName) { - return new Hall(hallName); + public static Hall create(String name) { + return new Hall(name); } } diff --git a/domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java b/domain/src/main/java/org/mandarin/booking/domain/hall/HallException.java similarity index 82% rename from domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java rename to domain/src/main/java/org/mandarin/booking/domain/hall/HallException.java index 62dd466..9747b60 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/venue/HallException.java +++ b/domain/src/main/java/org/mandarin/booking/domain/hall/HallException.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.domain.venue; +package org.mandarin.booking.domain.hall; import org.mandarin.booking.DomainException; diff --git a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java index 7810fa7..08d6753 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java +++ b/domain/src/main/java/org/mandarin/booking/domain/show/ShowRegisterRequest.java @@ -9,7 +9,7 @@ import org.mandarin.booking.domain.show.Show.Type; public record ShowRegisterRequest( - @NotNull(message = "venue id is required") + @NotNull(message = "hall id is required") Long hallId, @NotBlank(message = "title is required") From 1f9d1f0f7ba5cd9c9b42bf8c7792fcece5298d78 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 10:13:57 +0900 Subject: [PATCH 41/46] refactor show query handling with NullableQueryFilterBuilder for improved readability and null safety --- .../app/NullableQueryFilterBuilder.java | 91 +++++++++++++++++++ .../booking/app/show/ShowQueryRepository.java | 31 ++----- .../app/NullableQueryFilterBuilderTest.java | 38 ++++++++ .../adapter/GlobalExceptionHandler.java | 13 --- 4 files changed, 139 insertions(+), 34 deletions(-) create mode 100644 application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java create mode 100644 application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java diff --git a/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java new file mode 100644 index 0000000..93189fb --- /dev/null +++ b/application/src/main/java/org/mandarin/booking/app/NullableQueryFilterBuilder.java @@ -0,0 +1,91 @@ +package org.mandarin.booking.app; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.TemporalExpression; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; + +/** + * Querydsl BooleanBuilder를 감싸는 유틸리티 빌더 클래스 - 조건을 "값이 존재할 때만" 동적으로 추가할 수 있도록 도와줌 - 가독성을 높이고 null/blank 체크를 내부로 숨겨 호출부를 + * 단순하게 만듦 + */ +public class NullableQueryFilterBuilder { + private final BooleanBuilder builder = new BooleanBuilder(); + + private NullableQueryFilterBuilder() { + } + + public BooleanBuilder build() { + return builder; + } + + /** + * 값이 null이 아닐 경우에만 mapper를 통해 Predicate를 생성 후 and 조건으로 추가 예: when(type, t -> show.type.eq(t)) + */ + public NullableQueryFilterBuilder when(@Nullable T value, Function mapper) { + if (value != null) { + builder.and(mapper.apply(value)); + } + return this; + } + + /** + * 문자열이 null이 아니고 공백이 아닐 때만 mapper 적용 예: whenHasText(q, s -> show.title.containsIgnoreCase(s)) + */ + public NullableQueryFilterBuilder whenHasText(@Nullable String value, Function mapper) { + if (value != null && !value.isBlank()) { + builder.and(mapper.apply(value)); + } + return this; + } + + /** + * from, to 둘 다 존재할 때만 between 조건을 추가 + * + * @param from 요청 구간 시작 (예: LocalDate, LocalDateTime) + * @param to 요청 구간 종료 + * @param startExpr 엔티티의 시작 값 경로 (예: show.performanceStartDate) + * @param endExpr 엔티티의 종료 값 경로 (예: show.performanceEndDate) + */ + public > NullableQueryFilterBuilder whenInPeriod( + @Nullable T from, + @Nullable T to, + TemporalExpression startExpr, + TemporalExpression endExpr + ) { + if (from != null) { + builder.and(startExpr.after(from)); + } + if (to != null) { + builder.and(endExpr.before(to)); + } + return this; + } + + public static NullableQueryFilterBuilder builder() { + return new NullableQueryFilterBuilder(); + } + +// /** +// * 두 값이 모두 존재할 때만 mapper 적용 +// * 예: whenBoth(from, to, (f, t) -> show.performanceDate.between(f, t)) +// */ +// public NullableQueryFilterBuilder whenBoth(@Nullable L left, @Nullable R right, BiFunction mapper) { +// if (left != null && right != null) { +// builder.and(mapper.apply(left, right)); +// } +// return this; +// } +// +// /** +// * BooleanExpression을 직접 and 조건으로 추가 +// * (이미 조건식이 준비된 경우 사용) +// */ +// public NullableQueryFilterBuilder and(@Nullable BooleanExpression expr) { +// if (expr != null) { +// builder.and(expr); +// } +// return this; +// } +} diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index bb7b08b..5c10cda 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -1,12 +1,10 @@ package org.mandarin.booking.app.show; - import static com.querydsl.jpa.JPAExpressions.select; import static org.mandarin.booking.domain.hall.QHall.hall; import static org.mandarin.booking.domain.show.QShow.show; import static org.mandarin.booking.domain.show.QShowSchedule.showSchedule; -import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDate; import java.time.LocalDateTime; @@ -14,6 +12,7 @@ import lombok.RequiredArgsConstructor; import org.jspecify.annotations.Nullable; import org.mandarin.booking.adapter.SliceView; +import org.mandarin.booking.app.NullableQueryFilterBuilder; import org.mandarin.booking.domain.show.QShowResponse; import org.mandarin.booking.domain.show.Show; import org.mandarin.booking.domain.show.Show.Rating; @@ -45,7 +44,8 @@ public boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime e .from(show) .leftJoin(show.schedules, showSchedule) .where(show.hallId.eq(hallId)) - .where(showSchedule.startAt.before(endAt), showSchedule.endAt.after(startAt)) + .where(showSchedule.startAt.before(endAt), + showSchedule.endAt.after(startAt)) .fetchFirst() == null; } @@ -56,24 +56,13 @@ public SliceView fetch(@Nullable Integer page, @Nullable String q, @Nullable LocalDate from, @Nullable LocalDate to) { - BooleanBuilder builder = new BooleanBuilder(); - if (type != null) { - builder.and(show.type.eq(type)); - } - if (rating != null) { - builder.and(show.rating.eq(rating)); - } - if (q != null) { - builder.and(show.title.containsIgnoreCase(q)); - } - if (from != null && to != null) { - builder.and(show.performanceStartDate.after(from)) - .and(show.performanceEndDate.before(to)); - } else if (from != null) { - builder.and(show.performanceStartDate.after(from)); - } else if (to != null) { - builder.and(show.performanceEndDate.before(to)); - } + + var builder = NullableQueryFilterBuilder.builder() + .when(type, show.type::eq) + .when(rating, show.rating::eq) + .whenHasText(q, show.title::containsIgnoreCase) + .whenInPeriod(from, to, show.performanceStartDate, show.performanceEndDate) + .build(); List results = queryFactory .select(new QShowResponse( diff --git a/application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java b/application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java new file mode 100644 index 0000000..6678140 --- /dev/null +++ b/application/src/test/java/org/mandarin/booking/app/NullableQueryFilterBuilderTest.java @@ -0,0 +1,38 @@ +package org.mandarin.booking.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import com.querydsl.core.BooleanBuilder; +import org.junit.jupiter.api.Test; + +class NullableQueryFilterBuilderTest { + + @Test + void whenHasText_null_shouldIgnore() { + var result = NullableQueryFilterBuilder.builder() + .whenHasText(null, s -> fail()) + .build(); + + assertThat(result).isEqualTo(new BooleanBuilder()); + } + + @Test + void whenHasText_empty_shouldIgnore() { + var result = NullableQueryFilterBuilder.builder() + .whenHasText("", s -> fail()) + .build(); + + assertThat(result).isEqualTo(new BooleanBuilder()); + } + + @Test + void whenHasText_blank_shouldIgnore() { + var result = NullableQueryFilterBuilder.builder() + .whenHasText(" ", s -> fail()) + .build(); + + assertThat(result).isEqualTo(new BooleanBuilder()); + } + +} diff --git a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java index 1b67fc9..52f4860 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/GlobalExceptionHandler.java @@ -11,8 +11,6 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.HandlerMethodValidationException; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; @Slf4j @@ -37,17 +35,6 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e return new ErrorResponse(BAD_REQUEST, requireNonNull(defaultMessage)); } - @ExceptionHandler(HandlerMethodValidationException.class) - public ErrorResponse handleHandlerMethodValidationException(HandlerMethodValidationException ex) { - return new ErrorResponse(BAD_REQUEST, ex.getMessage()); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { - return new ErrorResponse(BAD_REQUEST, ex.getMessage()); - } - - @ExceptionHandler(NoHandlerFoundException.class) public ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException ex) { return new ErrorResponse(NOT_FOUND, ex.getMessage()); From 509251a20a9cdb7726f6d151a4051034f506aefd Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 19:51:41 +0900 Subject: [PATCH 42/46] refactor authentication and member-related classes for improved structure and readability --- .../app/hall/HallCommandRepository.java | 18 ----- .../booking/app/hall/HallQueryRepository.java | 4 +- .../booking/app/member/AuthService.java | 2 +- .../member}/CustomAuthenticationProvider.java | 3 +- .../app/member/MemberCommandRepository.java | 4 +- .../app/member/MemberQueryRepository.java | 14 ++-- .../app/member/MemberRegisterValidator.java | 2 +- .../booking/app/member/MemberService.java | 2 +- .../app/show/ShowCommandRepository.java | 4 +- .../booking/app/show/ShowQueryRepository.java | 10 +-- .../booking/app/show/ShowService.java | 2 +- .../CustomAuthenticationProviderTest.java | 2 +- .../mandarin/booking/utils/TestConfig.java | 10 +-- .../mandarin/booking/utils/TestFixture.java | 74 ++++++++++--------- .../booking/webapi/auth/login/POST_specs.java | 6 +- .../booking/webapi/member/POST_specs.java | 12 +-- .../booking/domain/member/Member.java | 2 + 17 files changed, 76 insertions(+), 95 deletions(-) delete mode 100644 application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java rename application/src/main/java/org/mandarin/booking/{adapter/security => app/member}/CustomAuthenticationProvider.java (94%) rename application/src/test/java/org/mandarin/booking/{adapter/security => app/member}/CustomAuthenticationProviderTest.java (96%) diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java deleted file mode 100644 index d05acce..0000000 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallCommandRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.mandarin.booking.app.hall; - -import lombok.RequiredArgsConstructor; -import org.mandarin.booking.domain.hall.Hall; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -@Repository -@Transactional -@RequiredArgsConstructor -public class HallCommandRepository { - private final HallRepository repository; - - public Hall insert(Hall hall) { - return repository.save(hall); - } -} - diff --git a/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java index 71e40ba..7eedc66 100644 --- a/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/hall/HallQueryRepository.java @@ -7,10 +7,10 @@ @Repository @Transactional(readOnly = true) @RequiredArgsConstructor -public class HallQueryRepository { +class HallQueryRepository { private final HallRepository repository; - public boolean existsById(Long hallId) { + boolean existsById(Long hallId) { return repository.existsById(hallId); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/AuthService.java b/application/src/main/java/org/mandarin/booking/app/member/AuthService.java index 73b0fa9..b3642c4 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/AuthService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/AuthService.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class AuthService implements AuthUseCase { +class AuthService implements AuthUseCase { private final SecurePasswordEncoder securePasswordEncoder; private final MemberQueryRepository queryRepository; private final TokenUtils tokenUtils; diff --git a/application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java b/application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java similarity index 94% rename from application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java rename to application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java index 2ad51a0..aee3e42 100644 --- a/application/src/main/java/org/mandarin/booking/adapter/security/CustomAuthenticationProvider.java +++ b/application/src/main/java/org/mandarin/booking/app/member/CustomAuthenticationProvider.java @@ -1,9 +1,8 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.app.member; import lombok.RequiredArgsConstructor; import org.mandarin.booking.AuthException; import org.mandarin.booking.adapter.CustomMemberAuthenticationToken; -import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.Member; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java index f666975..e5d62d3 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberCommandRepository.java @@ -8,10 +8,10 @@ @Repository @Transactional @RequiredArgsConstructor -public class MemberCommandRepository { +class MemberCommandRepository { private final MemberRepository jpaRepository; - public Member insert(Member member) { + Member insert(Member member) { return jpaRepository.save(member); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java index d91c351..2e8fade 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberQueryRepository.java @@ -9,18 +9,18 @@ @Repository @Transactional(readOnly = true) @RequiredArgsConstructor -public class MemberQueryRepository { +class MemberQueryRepository { private final MemberRepository jpaRepository; - public boolean existsByEmail(String email) { - return jpaRepository.existsByEmail(email); + Optional findByUserId(String userId) { + return jpaRepository.findByUserId(userId); } - public boolean existsByUserId(String userId) { - return jpaRepository.existsByUserId(userId); + boolean existsByEmail(String email) { + return jpaRepository.existsByEmail(email); } - public Optional findByUserId(String userId) { - return jpaRepository.findByUserId(userId); + boolean existsByUserId(String userId) { + return jpaRepository.existsByUserId(userId); } } diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java index 88897a6..f104cfb 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberRegisterValidator.java @@ -6,7 +6,7 @@ @Component @RequiredArgsConstructor -public class MemberRegisterValidator { +class MemberRegisterValidator { private final MemberQueryRepository queryRepository; void checkDuplicateEmail(String email) { diff --git a/application/src/main/java/org/mandarin/booking/app/member/MemberService.java b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java index 02083fc..6b0a658 100644 --- a/application/src/main/java/org/mandarin/booking/app/member/MemberService.java +++ b/application/src/main/java/org/mandarin/booking/app/member/MemberService.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class MemberService implements MemberRegisterer { +class MemberService implements MemberRegisterer { private final MemberRegisterValidator validator; private final MemberCommandRepository command; private final SecurePasswordEncoder securePasswordEncoder; diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java index 3344547..8c11876 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowCommandRepository.java @@ -8,10 +8,10 @@ @Repository @Transactional @RequiredArgsConstructor -public class ShowCommandRepository { +class ShowCommandRepository { private final ShowRepository jpaRepository; - public Show insert(Show show) { + Show insert(Show show) { return jpaRepository.save(show); } } diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index 5c10cda..4ac9925 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -25,20 +25,20 @@ @Repository @Transactional(readOnly = true) @RequiredArgsConstructor -public class ShowQueryRepository { +class ShowQueryRepository { private final ShowRepository jpaRepository; private final JPAQueryFactory queryFactory; - public boolean existsByName(String title) { + boolean existsByName(String title) { return jpaRepository.existsByTitle(title); } - public Show findById(Long showId) { + Show findById(Long showId) { return jpaRepository.findById(showId) .orElseThrow(() -> new ShowException("NOT_FOUND", "존재하지 않는 공연입니다.")); } - public boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime endAt) { + boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime endAt) { return queryFactory .selectOne() .from(show) @@ -49,7 +49,7 @@ public boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime e .fetchFirst() == null; } - public SliceView fetch(@Nullable Integer page, + SliceView fetch(@Nullable Integer page, @Nullable Integer size, @Nullable Type type, @Nullable Rating rating, diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java index e42ff4b..6113b80 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowService.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowService.java @@ -22,7 +22,7 @@ @Service @RequiredArgsConstructor -public class ShowService implements ShowRegisterer, ShowFetcher { +class ShowService implements ShowRegisterer, ShowFetcher { private final ShowCommandRepository commandRepository; private final ShowQueryRepository queryRepository; private final HallValidator hallValidator; diff --git a/application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java b/application/src/test/java/org/mandarin/booking/app/member/CustomAuthenticationProviderTest.java similarity index 96% rename from application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java rename to application/src/test/java/org/mandarin/booking/app/member/CustomAuthenticationProviderTest.java index de39963..602c382 100644 --- a/application/src/test/java/org/mandarin/booking/adapter/security/CustomAuthenticationProviderTest.java +++ b/application/src/test/java/org/mandarin/booking/app/member/CustomAuthenticationProviderTest.java @@ -1,4 +1,4 @@ -package org.mandarin.booking.adapter.security; +package org.mandarin.booking.app.member; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java index c8e2b25..741a42f 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestConfig.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestConfig.java @@ -4,9 +4,6 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.mandarin.booking.adapter.TokenUtils; -import org.mandarin.booking.app.hall.HallCommandRepository; -import org.mandarin.booking.app.member.MemberCommandRepository; -import org.mandarin.booking.app.show.ShowCommandRepository; import org.mandarin.booking.domain.member.SecurePasswordEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; @@ -26,10 +23,7 @@ public IntegrationTestUtils integrationTestUtils(@Autowired TestFixture testFixt } @Bean - public TestFixture testFixture(@Autowired MemberCommandRepository memberRepository, - @Autowired ShowCommandRepository showRepository, - @Autowired HallCommandRepository hallRepository, - @Autowired SecurePasswordEncoder securePasswordEncoder) { - return new TestFixture(entityManager, memberRepository, showRepository, hallRepository, securePasswordEncoder); + public TestFixture testFixture(@Autowired SecurePasswordEncoder securePasswordEncoder) { + return new TestFixture(entityManager, securePasswordEncoder); } } diff --git a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java index 436ecff..b23c7f2 100644 --- a/application/src/test/java/org/mandarin/booking/utils/TestFixture.java +++ b/application/src/test/java/org/mandarin/booking/utils/TestFixture.java @@ -13,9 +13,6 @@ import java.util.UUID; import java.util.stream.IntStream; import org.mandarin.booking.MemberAuthority; -import org.mandarin.booking.app.hall.HallCommandRepository; -import org.mandarin.booking.app.member.MemberCommandRepository; -import org.mandarin.booking.app.show.ShowCommandRepository; import org.mandarin.booking.domain.hall.Hall; import org.mandarin.booking.domain.member.Member; import org.mandarin.booking.domain.member.Member.MemberCreateCommand; @@ -28,20 +25,13 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; +@Transactional public class TestFixture { private final EntityManager entityManager; - private final MemberCommandRepository memberRepository; - private final ShowCommandRepository showRepository; - private final HallCommandRepository hallRepository; private final SecurePasswordEncoder securePasswordEncoder; - public TestFixture(EntityManager entityManager, MemberCommandRepository memberRepository, - ShowCommandRepository showRepository, HallCommandRepository hallRepository, - SecurePasswordEncoder securePasswordEncoder) { + public TestFixture(EntityManager entityManager, SecurePasswordEncoder securePasswordEncoder) { this.entityManager = entityManager; - this.memberRepository = memberRepository; - this.showRepository = showRepository; - this.hallRepository = hallRepository; this.securePasswordEncoder = securePasswordEncoder; } @@ -52,7 +42,7 @@ public Member insertDummyMember(String userId, String password) { password, generateEmail() ); - return memberRepository.insert( + return memberInsert( Member.create(command, securePasswordEncoder) ); } @@ -66,7 +56,7 @@ public Member insertDummyMember(String userId, String nickName, List generateShows(int showCount) { @@ -128,7 +119,7 @@ public void generateShows(int showCount, String titlePart) { var show = Show.create(hall.getId(), ShowCreateCommand.from(request)); ReflectionTestUtils.setField(show, "title", (char) random.nextInt('a', 'z') + titlePart + (char) random.nextInt('a', 'z')); - showRepository.insert(show); + showInsert(show); }); } @@ -149,7 +140,7 @@ public void generateShows(int showCount, int before, int after) { LocalDate.now().plusDays(random.nextInt(after)) ); var show = Show.create(hallId, ShowCreateCommand.from(request)); - showRepository.insert(show); + showInsert(show); }); } @@ -159,27 +150,20 @@ public boolean existsHallName(String name) { .getSingleResult() instanceof Long count) && count > 0; } - private void generateShow(Long hallId, Type type) { - var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); - var show = Show.create(hallId, ShowCreateCommand.from(request)); - showRepository.insert(show); - } - - private void generateShow(Long hallId, Rating rating) { - var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), rating.name()); - var show = Show.create(hallId, ShowCreateCommand.from(request)); - showRepository.insert(show); - } - - @Transactional public void removeShows() { entityManager.createQuery("DELETE FROM Show ").executeUpdate(); } - private Show generateShow(Long hallId) { - var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); + public Member findMemberByUserId(String userId) { + return entityManager.createQuery("SELECT m FROM Member m WHERE m.userId = :userId", Member.class) + .setParameter("userId", userId) + .getSingleResult(); + } + + private void generateShow(Long hallId, Type type) { + var request = validShowRegisterRequest(hallId, type.name(), randomEnum(Rating.class).name()); var show = Show.create(hallId, ShowCreateCommand.from(request)); - return showRepository.insert(show); + showInsert(show); } private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, String rating) { @@ -194,4 +178,26 @@ private ShowRegisterRequest validShowRegisterRequest(Long hallId, String type, S LocalDate.now().plusDays(30) ); } + + private void generateShow(Long hallId, Rating rating) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), rating.name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + showInsert(show); + } + + private Show generateShow(Long hallId) { + var request = validShowRegisterRequest(hallId, randomEnum(Type.class).name(), randomEnum(Rating.class).name()); + var show = Show.create(hallId, ShowCreateCommand.from(request)); + return showInsert(show); + } + + private Show showInsert(Show show) { + entityManager.persist(show); + return show; + } + + private Member memberInsert(Member member) { + entityManager.persist(member); + return member; + } } diff --git a/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java index b4a77aa..1eec28b 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/auth/login/POST_specs.java @@ -18,7 +18,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.mandarin.booking.TokenHolder; -import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.AuthRequest; import org.mandarin.booking.utils.IntegrationTest; import org.mandarin.booking.utils.IntegrationTestUtils; @@ -214,8 +213,7 @@ public class POST_specs { void 전달된_토큰에는_사용자의_userId가_포함되어야_한다( @Autowired IntegrationTestUtils integrationUtils, @Autowired TestFixture testFixture, - @Value("${jwt.token.secret}") String secretKey, - @Autowired MemberQueryRepository memberRepository + @Value("${jwt.token.secret}") String secretKey ) { // Arrange var userId = generateUserId(); @@ -242,7 +240,7 @@ public class POST_specs { assertThat(refreshTokenClaims.getPayload().get("userId")).isNotNull(); var currentUserId = accessTokenClaims.getPayload().get("userId").toString(); - var savedMember = memberRepository.findByUserId(currentUserId).orElseThrow(); + var savedMember = testFixture.findMemberByUserId(currentUserId); assertThat(savedMember).isNotNull(); } diff --git a/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java b/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java index 3ba2b08..7a1d810 100644 --- a/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java +++ b/application/src/test/java/org/mandarin/booking/webapi/member/POST_specs.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.mandarin.booking.app.member.MemberQueryRepository; import org.mandarin.booking.domain.member.MemberRegisterRequest; import org.mandarin.booking.domain.member.MemberRegisterResponse; import org.mandarin.booking.domain.member.SecurePasswordEncoder; @@ -16,6 +15,7 @@ import org.mandarin.booking.utils.IntegrationTestUtils; import org.mandarin.booking.utils.MemberFixture.NicknameGenerator; import org.mandarin.booking.utils.MemberFixture.PasswordGenerator; +import org.mandarin.booking.utils.TestFixture; import org.springframework.beans.factory.annotation.Autowired; @IntegrationTest @@ -43,7 +43,7 @@ public class POST_specs { @Test void 올바른_회원가입_요청을_하면_데이터베이스에_회원_정보가_저장된다( @Autowired IntegrationTestUtils testUtils, - @Autowired MemberQueryRepository memberRepository + @Autowired TestFixture testFixture ) { // Arrange var request = generateRequest(); @@ -55,7 +55,7 @@ public class POST_specs { ).assertSuccess(MemberRegisterResponse.class); // Assert - var matchingMember = memberRepository.findByUserId(request.userId()).orElseThrow(); + var matchingMember = testFixture.findMemberByUserId(request.userId()); assertThat(matchingMember).isNotNull(); } @@ -187,9 +187,9 @@ public class POST_specs { @Test void 비밀번호가_올바르게_암호화_된다( - @Autowired MemberQueryRepository memberRepository, @Autowired SecurePasswordEncoder securePasswordEncoder, - @Autowired IntegrationTestUtils testUtils + @Autowired IntegrationTestUtils testUtils, + @Autowired TestFixture testFixture ) { // Arrange String rawPassword = PasswordGenerator.generatePassword(); @@ -207,7 +207,7 @@ public class POST_specs { ).assertSuccess(MemberRegisterResponse.class); // Assert - var savedMember = memberRepository.findByUserId(request.userId()).orElseThrow(); + var savedMember = testFixture.findMemberByUserId(request.userId()); assertThat(securePasswordEncoder.matches(rawPassword, savedMember.getPasswordHash())).isTrue(); } diff --git a/domain/src/main/java/org/mandarin/booking/domain/member/Member.java b/domain/src/main/java/org/mandarin/booking/domain/member/Member.java index ec526a8..e29b759 100644 --- a/domain/src/main/java/org/mandarin/booking/domain/member/Member.java +++ b/domain/src/main/java/org/mandarin/booking/domain/member/Member.java @@ -6,6 +6,7 @@ import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.NaturalId; import org.mandarin.booking.MemberAuthority; import org.mandarin.booking.domain.AbstractEntity; @@ -16,6 +17,7 @@ public class Member extends AbstractEntity { private String nickName; + @NaturalId private String userId; private String passwordHash; From e9eb2feeffc16de207cd55a057becf87bbeedd08 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 20:33:51 +0900 Subject: [PATCH 43/46] refactor show and show schedule models to update hallId handling and improve documentation --- docs/specs/api/show_list_inquiry.md | 5 ++--- docs/specs/domain.md | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/specs/api/show_list_inquiry.md b/docs/specs/api/show_list_inquiry.md index 92b11b4..4f9e46e 100644 --- a/docs/specs/api/show_list_inquiry.md +++ b/docs/specs/api/show_list_inquiry.md @@ -22,7 +22,7 @@ - 쿼리 파라미터 - page (선택, 기본=0, 정수 >= 0): 페이지 번호 - - size (선택, 기본=10, 1~100): 페이지 크기 + - size (선택, 기본=10): 페이지 크기 - type (선택): 공연 유형 (MUSICAL, PLAY, CONCERT, OPERA, DANCE, CLASSICAL, ETC) - rating (선택): 관람 등급 (ALL, AGE12, AGE15, AGE18) - q (선택): 공연 제목 검색 키워드 @@ -37,8 +37,7 @@ - 정렬: 1) performanceStartDate ASC - 2) title ASC - 3) showId ASC (타이 브레이커 최종) + 2) title ASC(title이 unique기 때문에 마지막 정렬조건으로 충분) - 페이지네이션: - page는 0-기반 인덱스다. diff --git a/docs/specs/domain.md b/docs/specs/domain.md index 72f4ec7..5b888fc 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -305,6 +305,7 @@ erDiagram Show { BIGINT id PK + BIGINT hallId string title enum type "MUSICAL|PLAY|CONCERT|OPERA|DANCE|CLASSICAL|ETC" enum rating "ALL|AGE12|AGE15|AGE18" @@ -317,7 +318,6 @@ erDiagram ShowSchedule { BIGINT id PK BIGINT showId FK - BIGINT hallId datetime startAt datetime endAt int runtimeMinutes From 2289a7292be4035998a63a28f8d33e952b1bf3d7 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 20:37:35 +0900 Subject: [PATCH 44/46] update todo list with hall registration tasks and additional show registration considerations --- docs/todo.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index 1e8ad1c..2280043 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -27,6 +27,11 @@ - [x] public 떡칠하지 말고 기본 접근제어자 적극 활용 - [x] Spring Modulith 사용 가능한지 점검 +2025.09.23 +- [ ] hall register + - [ ] register with registerer name + - [ ] register with seats + --- -- [ ] hall register +- [ ] show register에 grade 추가 고민 From 4af85adff820d10ccd6fa1195c18f10f92d0e1cb Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 20:42:52 +0900 Subject: [PATCH 45/46] refactor show query handling to simplify SliceView construction and remove unnecessary hasNext calculation --- .../booking/app/show/ShowQueryRepository.java | 16 +++++++--------- .../org/mandarin/booking/adapter/SliceView.java | 3 +++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java index 4ac9925..c32473c 100644 --- a/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java +++ b/application/src/main/java/org/mandarin/booking/app/show/ShowQueryRepository.java @@ -50,12 +50,12 @@ boolean canScheduleOn(Long hallId, LocalDateTime startAt, LocalDateTime endAt) { } SliceView fetch(@Nullable Integer page, - @Nullable Integer size, - @Nullable Type type, - @Nullable Rating rating, - @Nullable String q, - @Nullable LocalDate from, - @Nullable LocalDate to) { + @Nullable Integer size, + @Nullable Type type, + @Nullable Rating rating, + @Nullable String q, + @Nullable LocalDate from, + @Nullable LocalDate to) { var builder = NullableQueryFilterBuilder.builder() .when(type, show.type::eq) @@ -83,8 +83,6 @@ SliceView fetch(@Nullable Integer page, .limit(size + 1) .fetch(); - boolean hasNext = results.size() > size; - - return new SliceView<>(results, page, size, hasNext); + return new SliceView<>(results, page, size); } } diff --git a/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java index 481824c..6f5e4cb 100644 --- a/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java +++ b/internal/src/main/java/org/mandarin/booking/adapter/SliceView.java @@ -11,4 +11,7 @@ public record SliceView( @JsonProperty("size") int size, @JsonProperty("hasNext") boolean hasNext ) { + public SliceView(List contents, int page, int size) { + this(contents, page, size, contents.size() > size); + } } From c5588757f73a08f29a9c32fcea1f89dd377e7df6 Mon Sep 17 00:00:00 2001 From: YeaChan05 Date: Mon, 22 Sep 2025 20:46:02 +0900 Subject: [PATCH 46/46] add initial implementation for performance-sensitive show list retrieval and modularization --- docs/devlog/250922.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/devlog/250922.md diff --git a/docs/devlog/250922.md b/docs/devlog/250922.md new file mode 100644 index 0000000..aea31b7 --- /dev/null +++ b/docs/devlog/250922.md @@ -0,0 +1,10 @@ +## 예찬 + +공연 목록 조회라는 사실상 가장 조회 성능에 민감한 부분이 될 부분을 개발했다. 추후 쿼리튜닝이나 캐싱을 통해 개선을 해야겠지만 당장에는 기능 구현한것만으로 만족. + +모듈화를 하면서 일부 누락되었던 package-private화도 마무리. 이제 얼추 외곽이 잡힌 애플리케이션이라는 생각이 든다. + +뭔가 페이징 조회 객체를 통일하는 과정에서 Spring이 제공하는 PageResponse를 커스텀해서 쓸까도 고민하긴 했는데 내가 만단 POJO 객체를 더 잘 쓰는게 더 좋지 않을까? 하는생각으로 그냥 새로 +만들어봤다. 제어하기에도 쉽고 부수효과 확인도 편하니까. + +이제 앞으로가 문제다. 요구사항을 잡기가 쉽지가 않은거같다. 도메인 개요에 대한 어느정도 문서화를 하고나서 다시 해야할까? 이런생각도 들긴 한다.